Skip to content

Commit 1902693

Browse files
authored
Merge pull request ckan#9016 from mutantsan/add-toast-js-module
feat: add toast.js module
2 parents cfe9605 + 964a6be commit 1902693

File tree

16 files changed

+744
-51
lines changed

16 files changed

+744
-51
lines changed

changes/9016.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add reusable `ckan.toast` module that displays Bootstrap 5 toast notifications across CKAN

ckan/public-midnight-blue/base/css/main-rtl.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15589,6 +15589,14 @@ input[id^=lang-].btn-check + label.btn:hover {
1558915589
font-style: normal;
1559015590
font-weight: 100 900;
1559115591
}
15592+
@keyframes reverseProgress {
15593+
from {
15594+
width: 100%;
15595+
}
15596+
to {
15597+
width: 0%;
15598+
}
15599+
}
1559215600
@media (min-width: 576px) {
1559315601
.wrapper:before {
1559415602
left: initial;

ckan/public-midnight-blue/base/css/main.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15588,4 +15588,12 @@ input[id^=lang-].btn-check + label.btn:hover {
1558815588
src: url("../../../base/fonts/NotoSans-VariableFont_wdth,wght.ttf") format("truetype");
1558915589
font-style: normal;
1559015590
font-weight: 100 900;
15591+
}
15592+
@keyframes reverseProgress {
15593+
from {
15594+
width: 100%;
15595+
}
15596+
to {
15597+
width: 0%;
15598+
}
1559115599
}

ckan/public-midnight-blue/base/javascript/htmx-ckan.js

Lines changed: 116 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*
77
*/
88
var csrf_field = $('meta[name=csrf_field_name]').attr('content');
9-
var csrf_token = $('meta[name='+ csrf_field +']').attr('content');
9+
var csrf_token = $('meta[name=' + csrf_field + ']').attr('content');
1010

1111
htmx.on('htmx:configRequest', (event) => {
1212
if (csrf_token) {
@@ -17,12 +17,12 @@ htmx.on('htmx:configRequest', (event) => {
1717
function htmx_cleanup_before_swap(event) {
1818
// Dispose any active tooltips before HTMX swaps the DOM
1919
event.detail.target.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(
20-
el => {
21-
const tooltip = bootstrap.Tooltip.getInstance(el)
22-
if (tooltip) {
23-
tooltip.dispose()
24-
}
25-
})
20+
el => {
21+
const tooltip = bootstrap.Tooltip.getInstance(el)
22+
if (tooltip) {
23+
tooltip.dispose()
24+
}
25+
})
2626
}
2727
document.body.addEventListener("htmx:beforeSwap", htmx_cleanup_before_swap);
2828
document.body.addEventListener("htmx:oobBeforeSwap", htmx_cleanup_before_swap);
@@ -40,41 +40,126 @@ function htmx_initialize_ckan_modules(event) {
4040
}
4141

4242
event.detail.target.querySelectorAll('[data-bs-toggle="tooltip"]'
43-
).forEach(node => {
43+
).forEach(node => {
4444
bootstrap.Tooltip.getOrCreateInstance(node)
4545
})
46+
4647
event.detail.target.querySelectorAll('.show-filters').forEach(node => {
47-
node.onclick = function() {
48+
node.onclick = function () {
4849
$("body").addClass("filters-modal")
4950
}
5051
})
52+
5153
event.detail.target.querySelectorAll('.hide-filters').forEach(node => {
52-
node.onclick = function() {
54+
node.onclick = function () {
5355
$("body").removeClass("filters-modal")
5456
}
5557
})
5658
}
57-
document.body.addEventListener("htmx:afterSwap", htmx_initialize_ckan_modules);
59+
60+
document.body.addEventListener("htmx:afterSwap", function (event) {
61+
htmx_initialize_ckan_modules(event);
62+
63+
const element = event.detail.requestConfig?.elt;
64+
if (!element) return;
65+
66+
const toastHandler = new ToastHandler(element);
67+
68+
if (event.detail.successful) {
69+
toastHandler.showToast();
70+
}
71+
});
72+
73+
/**
74+
* ToastHandler parses a single JSON-like attribute from an HTML element
75+
* and triggers a CKAN toast notification.
76+
*
77+
* It expects a `data-hx-toast` attribute to be present on the element,
78+
* which should contain a JSON string with the toast configuration:
79+
*
80+
* <div hx-target='..' hx-get='...' data-hx-toast='{"message": "Something happened", "type": "info"}'></div>
81+
*
82+
* Use it together with HTMX to show notifications after actions.
83+
*
84+
* @class
85+
* @param {HTMLElement} element - The element containing the toast config.
86+
*/
87+
class ToastHandler {
88+
constructor(element) {
89+
this.attrKey = "data-hx-toast";
90+
this.defaultToastOptions = {
91+
type: "success",
92+
title: ckan.i18n._("Notification"),
93+
};
94+
this.options = this.buildToastOptions(element);
95+
}
96+
97+
/**
98+
* Parses the JSON string from the toast attribute and merges with defaults.
99+
*
100+
* @param {HTMLElement} element
101+
*
102+
* @returns {Object}
103+
*/
104+
buildToastOptions(element) {
105+
const attrValue = element.getAttribute(this.attrKey);
106+
if (!attrValue) return this.defaultToastOptions;
107+
108+
try {
109+
const parsed = JSON.parse(attrValue);
110+
return { ...this.defaultToastOptions, ...parsed };
111+
} catch (e) {
112+
console.error(`Invalid JSON in ${this.attrKey}:`, attrValue);
113+
return {
114+
...this.defaultToastOptions,
115+
message: `Invalid toast config: ${e.message}`,
116+
type: "danger"
117+
};
118+
}
119+
}
120+
121+
showToast() {
122+
if (!this.options.message) return;
123+
ckan.toast(this.options);
124+
}
125+
}
126+
58127
document.body.addEventListener("htmx:oobAfterSwap", htmx_initialize_ckan_modules);
59128

60-
document.body.addEventListener("htmx:responseError", function(event) {
61-
const xhr = event.detail.xhr
62-
const error = $(xhr.response).find('#error-content')
63-
const headerHTML = error.find('h1').remove().html() || `${xhr.status} ${xhr.statusText}`
64-
const messageHTML = error.html() || event.detail.error
65-
$('#responseErrorToast').remove()
66-
$(`
67-
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
68-
<div id="responseErrorToast" class="toast hide" role="alert" aria-live="assertive" aria-atomic="true">
69-
<div class="toast-header">
70-
<strong class="me-auto text-danger">${headerHTML}</strong>
71-
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="${ckan.i18n._("Close")}"></button>
72-
</div>
73-
<div class="toast-body">
74-
${messageHTML}
75-
</div>
76-
</div>
77-
</div>
78-
`).appendTo('body')
79-
$('#responseErrorToast').toast('show')
129+
document.body.addEventListener("htmx:responseError", function (event) {
130+
const xhr = event.detail.xhr;
131+
132+
if (xhr.response.startsWith("<!doctype html>")) {
133+
const error = $(xhr.response).find('#error-content');
134+
var message = error.html() || event.detail.error;
135+
} else {
136+
var message = xhr.responseText;
137+
}
138+
139+
ckan.toast({
140+
message: message.trim().replace(/^"(.*)"$/, '$1'),
141+
type: "danger",
142+
title: `${xhr.status} ${xhr.statusText}`
143+
});
144+
})
145+
146+
document.addEventListener("htmx:confirm", function (e) {
147+
// The event is triggered on every trigger for a request, so we need to check if the element
148+
// that triggered the request has a confirm question set via the hx-confirm attribute,
149+
// if not we can return early and let the default behavior happen
150+
if (!e.detail.question) return
151+
152+
// This will prevent the request from being issued to later manually issue it
153+
e.preventDefault()
154+
155+
ckan.confirm({
156+
message: e.detail.question,
157+
type: "primary",
158+
centered: true,
159+
onConfirm: () => {
160+
// If the user confirms, we manually issue the request
161+
// true to skip the built-in window.confirm()
162+
e.detail.issueRequest(true);
163+
}
164+
});
80165
})

0 commit comments

Comments
 (0)