Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Using htmx to Refresh ProcessWire Frontend Content

Using htmx to Refresh ProcessWire Frontend Content

Demo Notes

Example of how htmx can be used to refresh ProcessWire frontend content. The original challenge/question can be found here in the ProcessWire forums. The thread also includes could explanations especially in respect of htmx attributes.

Please note that the add new location functionality is not implemented on the server-side in this code.

Secondly, note that this example makes use of alpine.js and tailwind css just for the pizzaz. These are not required by htmx.

To let ProcessWire recognise htmx requests, please refer to this thread. Depending on the approach you take from there, you might not even need a JavaScript file! In this example though, we do have a JavaScript file just because we want to use alpine.js (for notifications) and we need htmx to talk to alpine.js. We also need the JavaScript file to tell htmx to add XMLHttpRequest to its request headers so that ProcessWire's $config->ajax will understand the request.

Remember to reference the JavaScipt file UpdateProcessWireFrontendContentUsingHtmxDemo.js somewhere in your template's head, e.g. in _main.php:

  <!-- INCLUDE TAILWIND @note: full tailwind for quick demo only! not for production! -->
  <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
  <link href="//fonts.googleapis.com/css?family=Lusitana:400,700|Quattrocento:400,700" rel="stylesheet" type="text/css" />
  <!-- INCLUDE CUSTOM STYLE SHEET IF NEEDED -->
  <link rel="stylesheet" type="text/css" href="<?php echo $config->urls->templates ?>styles/main.css" />
  <!-- INCLUDE HTMX -->
  <script src="https://unpkg.com/htmx.org@1.5.0"></script>
  <!-- INCLUDE ALPINE -->
  <script src="//unpkg.com/alpinejs" defer></script>
  <!-- INCLUDE CUSTOM JS FILE -->
  <script src="<?php echo $config->urls->templates ?>scripts/UpdateProcessWireFrontendContentUsingHtmxDemo.js"></script>

Demo Video

Resources

  1. ProcessWire.
  2. htmx.
  3. Alpine.js.
  4. Tailwind CSS.
const UpdateProcessWireFrontendContentUsingHtmxDemo = {
initHTMXXRequestedWithXMLHttpRequest: function () {
document.body.addEventListener("htmx:configRequest", (event) => {
// @note: ADD THIS!!! if not using hx-include='token input'
// const csrf_token = UpdateProcessWireFrontendContentUsingHtmxDemo.getCSRFToken()
// event.detail.headers[csrf_token.name] = csrf_token.value
// add XMLHttpRequest to header to work with $config->ajax
event.detail.headers["X-Requested-With"] = "XMLHttpRequest"
})
},
listenToHTMXRequests: function () {
// after settle
htmx.on("htmx:afterSettle", function (event) {
// RUN POST SETTLE OPS
// @note: hidden element
const noticeElement = document.getElementById("location_notice")
let eventDetail = { type: "error", text: "Server encountered error" }
// get the notice and notice type from the hidden element
if (noticeElement) {
eventDetail = {
type: noticeElement.dataset.noticeType,
text: noticeElement.value,
}
}
const eventName = "notice"
UpdateProcessWireFrontendContentUsingHtmxDemo.dispatchCustomEvent(
eventName,
eventDetail
)
})
},
getCSRFToken: function () {
// find hidden input with 'csrf token'
const tokenInput = htmx.find("._post_token")
return tokenInput
},
/**
* Dispatch a custom event as requrested.
* @param {string} eventName The name of the custom event to dispatch.
* @param {any} eventDetail The event details to attach to the event detail object.
* @param {Node} elem Optional element to trigger the event from, else window.
*/
dispatchCustomEvent: function (eventName, eventDetail, elem) {
const event = new CustomEvent(eventName, { detail: eventDetail })
if (elem) {
elem.dispatchEvent(event)
} else {
window.dispatchEvent(event)
}
},
// @credit: https://tailwindcomponents.com/component/alphine-js-toast-notification
noticesHandler: function () {
return {
notices: [],
visible: [],
add(notice) {
notice.id = Date.now()
this.notices.push(notice)
this.fire(notice.id)
},
fire(id) {
this.visible.push(this.notices.find((notice) => notice.id == id))
const timeShown = 2500 * this.visible.length
setTimeout(() => {
this.remove(id)
}, timeShown)
},
remove(id) {
const notice = this.visible.find((notice) => notice.id == id)
const index = this.visible.indexOf(notice)
this.visible.splice(index, 1)
},
getIcon(notice) {
if (notice.type == "success")
return "<div class='text-green-500 rounded-full bg-white float-left ml-3'><svg width='1.8em' height='1.8em' viewBox='0 0 16 16' class='bi bi-check' fill='currentColor' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' d='M10.97 4.97a.75.75 0 0 1 1.071 1.05l-3.992 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.236.236 0 0 1 .02-.022z'/></svg></div>"
else if (notice.type == "info")
return "<div class='text-blue-500 rounded-full bg-white float-left ml-3'><svg width='1.8em' height='1.8em' viewBox='0 0 16 16' class='bi bi-info' fill='currentColor' xmlns='http://www.w3.org/2000/svg'><path d='M8.93 6.588l-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588z'/><circle cx='8' cy='4.5' r='1'/></svg></div>"
else if (notice.type == "warning")
return "<div class='text-orange-500 rounded-full bg-white float-left ml-3'><svg width='1.8em' height='1.8em' viewBox='0 0 16 16' class='bi bi-exclamation' fill='currentColor' xmlns='http://www.w3.org/2000/svg'><path d='M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z'/></svg></div>"
else if (notice.type == "error")
return "<div class='text-red-500 rounded-full bg-white float-left ml-3'><svg width='1.8em' height='1.8em' viewBox='0 0 16 16' class='bi bi-x' fill='currentColor' xmlns='http://www.w3.org/2000/svg'><path fill-rule='evenodd' d='M11.854 4.146a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708-.708l7-7a.5.5 0 0 1 .708 0z'/><path fill-rule='evenodd' d='M4.146 4.146a.5.5 0 0 0 0 .708l7 7a.5.5 0 0 0 .708-.708l-7-7a.5.5 0 0 0-.708 0z'/></svg></div>"
},
}
},
}
/**
* DOM ready
*
*/
document.addEventListener("DOMContentLoaded", function (event) {
if (typeof htmx !== "undefined") {
// init htmx with X-Requested-With
UpdateProcessWireFrontendContentUsingHtmxDemo.initHTMXXRequestedWithXMLHttpRequest()
// listen to htmx requests
UpdateProcessWireFrontendContentUsingHtmxDemo.listenToHTMXRequests()
}
})
//--------------
<?php
namespace ProcessWire;
// WE GOT AN AJAX REQUEST
if ($config->ajax) {
// check CSRF
if (!$session->CSRF->hasValidToken()) {
// form submission is NOT valid
throw new WireException('CSRF check failed!');
}
$id = 0;
$options = [];
// get previously removed locations (keeping things in sync)
$removedLocations = $session->get('removedLocations');
//---------
if ((int) $input->post->location_remove_id) {
// REMOVING LOCATION
$mode = 'remove';
$notice = "Removed";
$id = (int) $input->post->location_remove_id;
} else if ((int) $input->post->location_add_id) {
// ADDING LOCATION
$mode = 'add';
$notice = 'Added';
$id = (int) $input->post->location_add_id;
}
// check if we have the page, else error
$locationTitle = $pages->getRaw("id={$id}", 'title');
// location not found
if (empty($locationTitle)) {
// handle error
$noticeType = "error";
$notice = "Location could not be " . strtolower($notice);
}
// location found
else {
// add removed to session
if ($mode === 'remove') {
$removedLocations[] = $id;
// reset session
$session->set('removedLocations', $removedLocations);
}
// build options
$options['skip_pages_ids'] = $removedLocations;
//----------------
$noticeType = "success";
$notice .= " {$locationTitle}";
}
//-----------
// build updated content
$out = buildLocationCards($page, $options);
// @note - here we always return one input <only!></only!>
$out .= "<input type='hidden' id='location_notice' name='location_notice' data-notice-type='{$noticeType}' value='{$notice}'>";
echo $out;
// halt proceedings
$this->halt();
}
function buildCard(Page $page) {
$firstImage = $page->images->first();
$imageThumbURL = $firstImage->height(260)->url;
//-----------------
// component @credits: https://tailwindcomponents.com/component/simple-card
$out =
"<div class='max-w-xs rounded overflow-hidden shadow-lg my-2'>" .
"<img class='w-full' src='{$imageThumbURL}' alt='{$firstImage->description}'>" .
"<div class='px-6 py-4'>" .
"<div class='font-bold text-xl mb-2'>{$page->title}</div>" .
// ripTags(wordLimiter($page->body)) .
wordLimiter($page->body, 60) .
"</div>" .
"<div class='px-6 py-4'>" .
getActionButtons($page) .
"</div>" .
"</div>";
return $out;
}
/**
* Wordlimiter cuts a textarea only after complete words not between
* used seo function and in some templates
* @credit: @soma: https://processwire.com/talk/topic/3429-how-to-set-text-linecharacter-limits-in-templates/?do=findComment&comment=33779
*/
function wordLimiter($str = '', $limit = 120, $endstr = '...') {
if ($str == '') return '';
if (strlen($str) <= $limit) return $str;
$out = substr($str, 0, $limit);
$pos = strrpos($out, " ");
if ($pos > 0) {
$out = substr($out, 0, $pos);
}
$out .= $endstr;
return $out;
}
/**
* Alternative with regex for striptags function
* used for seo function and in some templates
* @credit: @soma: https://processwire.com/talk/topic/3429-how-to-set-text-linecharacter-limits-in-templates/?do=findComment&comment=33779
*/
function ripTags($string) {
// ----- remove HTML TAGs -----
$string = preg_replace('/<[^>]*>/', ' ', $string);
// ----- remove control characters -----
$string = str_replace("\r", '', $string); // --- replace with empty space
$string = str_replace("\n", ' ', $string); // --- replace with space
$string = str_replace("\t", ' ', $string); // --- replace with space
// ----- remove multiple spaces -----
$string = trim(preg_replace('/ {2,}/', ' ', $string));
return $string;
}
function getActionButtons(Page $page) {
// @note: in this example we SWAP the whole div#locations!
$out =
// ADD LOCATION
"<a hx-post='./' hx-target='#locations' hx-vals='{\"location_add_id\": \"{$page->id}\"}' hx-include='._post_token' hx-indicator='#locations_spinner_indicator' class='cursor-pointer inline-block bg-grey-lighter rounded-lg px-3 py-1 text-sm font-semibold text-grey-darker mr-2 hover:bg-green-500 hover:text-white'>Add</a>" .
// REMOVE LOCATION
"<a hx-post='./' hx-target='#locations' hx-vals='{\"location_remove_id\": \"{$page->id}\"}' hx-include='._post_token' hx-indicator='#locations_spinner_indicator' class='cursor-pointer inline-block bg-grey-lighter rounded-lg px-3 py-1 text-sm font-semibold text-grey-darker mr-2 hover:bg-red-500 hover:text-white'>Remove</a>";
return $out;
}
function buildLocationCards(Page $parentPage, $options = []) {
$defaultOptions = ['skip_pages_ids' => []];
if (!empty($options)) {
$options = array_merge($defaultOptions, $options);
} else {
$options = $defaultOptions;
}
$locations = $parentPage->children;
$allLocationsRemoved = !empty($options['skip_pages_ids']) && (count($options['skip_pages_ids']) === $locations->count);
//--------------
// @note: in this example, we SWAP whole div#locations!
$out = "<div id='locations' class='flex flex-wrap gap-9 mb-10'>";
// locations instruction
$out .=
"<h3 class='text-4xl w-full'>Select Locations" .
"<span id='locations_spinner_indicator' class='htmx-indicator text-green-200 fa fa-fw fa-spin fa-spinner'></span>" .
"</h3>";
// if we have some locations
if (!$allLocationsRemoved) {
foreach ($locations as $location) {
// if skipping some locations/locationren
if (in_array($location->id, $options['skip_pages_ids'])) {
continue;
};
//-----------------
// vertical card - GRID
// $content .= "<div id='location_{$location->id}' class='col-span-full md:col-span-4 lg:col-span-3'>" . buildCard($location) . "</div>";
// vertical card - FLEX
$out .= "<div id='location_{$location->id}' class=''>" . buildCard($location) . "</div>";
}
} else {
$out .= "<p class='text-2xl text-white bg-red-600 mt-7 py-3 px-5'>All Locations have been removed!</p>";
}
// close flex
$out .= "</div>";
// add notices handler
$out .= buildNoticesHandler();
// add CSRF
$out .= wire('session')->CSRF->renderInput();
return $out;
}
// @credit: https://tailwindcomponents.com/component/alphine-js-toast-notification
function buildNoticesHandler() {
$out = "
<div x-data='HtmxChallenges.noticesHandler()' @notice.window='add(\$event.detail)'>
<div class='fixed right-0 top-0 m-5 w-1/2 xl:w-1/5 lg:w-1/4 md:w-2/5 sm:w-1/2'>
<template x-for='notice of notices' :key='notice.id'>
<div x-show='visible.includes(notice)' x-transition:enter='transition ease-in duration-200'
x-transition:enter-start='transform opacity-0 translate-y-2'
x-transition:enter-end='transform opacity-100' x-transition:leave='transition ease-out duration-500'
x-transition:leave-start='transform translate-x-0 opacity-100'
x-transition:leave-end='transform translate-x-full opacity-0' @click='remove(notice.id)'
class='py-2 px-3 shadow-md mb-2 border-r-4 grid grid-cols-4' :class='{
\"bg-green-500 border-green-700\": notice.type === `success`,
\"bg-blue-400 border-blue-700\": notice.type === `info`,
\"bg-yellow-400 border-yellow-700\": notice.type === `warning`,
\"bg-red-500 border-red-700\": notice.type === `error`,
}' style='pointer-events:all'>
<div class='col-start-1 col-span-3'>
<div class='text-white text-right'><span x-text='notice.text'></span></div>
</div>
<div class='col-start-4 col-span-1' x-html='getIcon(notice)'></div>
</div>
</template>
</div>
</div>";
return $out;
}
//-------------------
// USUAL NON-AJAX CONTENT
// Primary content is the page's body copy
// $content = $page->body;
$content = "";
// build options for page cards, e.g. for those to remove
$options = [];
// init removed locations for session if one not present already
if (empty($session->get('removedLocations'))) {
$session->set('removedLocations', []);
} else {
// current removed locations
$removedLocations = $session->get('removedLocations');
// if we have removed locations, set them in options
if (!empty($removedLocations)) {
$options['skip_pages_ids'] = $removedLocations;
}
}
// ---------------
// build the cards
// @note: off to _main.php
$content .= buildLocationCards($page, $options);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment