Skip to content

Commit

Permalink
HTMX boilerplate
Browse files Browse the repository at this point in the history
  • Loading branch information
rafalp committed May 25, 2024
1 parent 2095831 commit d11d869
Show file tree
Hide file tree
Showing 14 changed files with 260 additions and 27 deletions.
41 changes: 41 additions & 0 deletions frontend/src/htmxErrors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { error } from "./snackbars"

function handleResponseError({ detail }) {
const message = getResponseErrorMessage(detail.xhr)
error(message)
}

function getResponseErrorMessage(xhr) {
if (xhr.getResponseHeader('content-type') === "application/json") {
const data = JSON.parse(xhr.response)
if (data.error) {
return data.error
}
}

if (xhr.status === 404) {
return pgettext("htmx response error", "Not found")
}

if (xhr.status === 403) {
return pgettext("htmx response error", "Permission denied")
}

return pgettext("htmx response error", "Unexpected error")
}

function handleSendError() {
const message = pgettext("htmx response error", "Site could not be reached")
error(message)
}

function handleTimeoutError() {
const message = pgettext("htmx response error", "Site took too long to reply")
error(message)
}

export function setupHtmxErrors() {
document.addEventListener("htmx:responseError", handleResponseError)
document.addEventListener("htmx:sendError", handleSendError)
document.addEventListener("htmx:timeout", handleTimeoutError)
}
24 changes: 23 additions & 1 deletion frontend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import "htmx.org"
import OrderedList from "misago/utils/ordered-list"
import "misago/style/index.less"
import "./ajaxIndicator"
import { setupHtmxErrors } from "./htmxErrors"
import { startLiveTimestamps } from "./liveTimestamps"
import "./snackbars"
import * as snackbars from "./snackbars"

export class Misago {
constructor() {
Expand Down Expand Up @@ -60,6 +61,26 @@ export class Misago {
return undefined
}
}

snackbar(type, message) {
snackbars.snackbar(type, message)
}

snackbarInfo(message) {
snackbars.info(message)
}

snackbarSuccess(message) {
snackbars.success(message)
}

snackbarWarning(message) {
snackbars.warning(message)
}

snackbarError(message) {
snackbars.error(message)
}
}

// create singleton
Expand All @@ -72,3 +93,4 @@ window.misago = misago
export default misago

startLiveTimestamps()
setupHtmxErrors()
62 changes: 58 additions & 4 deletions frontend/src/snackbars.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,60 @@
document.addEventListener("htmx:afterSettle", () => {
const root = document.getElementById("misago-snackbars")
root.querySelectorAll(".snackbar").forEach((element) => {
const SNACKBAR_TTL = 6

const container = document.getElementById("misago-snackbars")
let timeout = null

export function removeSnackbars() {
container.replaceChildren()
}

function renderSnackbars() {
if (timeout) {
window.clearTimeout(timeout)
}

container.querySelectorAll(".snackbar").forEach((element) => {
element.classList.add("in")
})
})

timeout = window.setTimeout(() => {
container.querySelectorAll(".snackbar").forEach((element) => {
element.classList.add("out")
timeout = window.setTimeout(removeSnackbars, 1000)
})
}, SNACKBAR_TTL * 1000)
}

export function snackbar(type, message) {
removeSnackbars()

if (timeout) {
window.clearTimeout(timeout)
}

const element = document.createElement("div")
element.classList.add("snackbar")
element.classList.add("snackbar-" + type)
element.innerText = message
element.role = "alert"
container.appendChild(element)

timeout = window.setTimeout(renderSnackbars, 100)
}

export function info(message) {
snackbar("info", message)
}

export function success(message) {
snackbar("success", message)
}

export function warning(message) {
snackbar("warning", message)
}

export function error(message) {
snackbar("danger", message)
}

document.addEventListener("htmx:afterSettle", renderSnackbars)
44 changes: 38 additions & 6 deletions frontend/src/style/misago/alerts-snackbar.less
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

#misago-snackbars {
position: fixed;
top: 72px;
top: @snackbars-top-offset;
padding: 0 @snackbars-gutter;
width: 100%;
z-index: 10000;

Expand All @@ -17,17 +18,48 @@
}

.snackbar {
padding: 8px 12px;
padding: @snackbar-v2-padding;

background-color: #111;
border-radius: @border-radius-base;
color: #fff;
background-color: @snackbar-v2-bg;
border-radius: @snackbar-v2-border-radius;

position: relative;
top: -12px;
opacity: 0;

color: @snackbar-v2-color;
font-size: @snackbar-v2-size;

@media screen and (min-width: @snackbar-v2-lg-breakpoint) {
padding: @snackbar-v2-lg-padding;
border-radius: @snackbar-v2-lg-border-radius;
font-size: @snackbar-v2-lg-size;
}

&.snackbar-success {
background-color: @snackbar-v2-success-bg;
color: @snackbar-v2-success-color;
}

&.snackbar-warning {
background-color: @snackbar-v2-warning-bg;
color: @snackbar-v2-warning-color;
}

&.snackbar-danger {
background-color: @snackbar-v2-danger-bg;
color: @snackbar-v2-danger-color;
}

&.in {
top: 0;
opacity: 1;
transition: 200ms ease;
transition: 300ms ease;
}

&.out {
opacity: 0;
transition: 300ms ease;
}
}

Expand Down
28 changes: 27 additions & 1 deletion frontend/src/style/misago/variables.less
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,36 @@
@navbar-item-badge-color: #fff;
@navbar-item-badge-bg: #dc2626;

// Alert
// Alerts

@alert-border-radius: 0;

// Snackbars v2

@snackbars-top-offset: @navbar-height + @line-height-computed;
@snackbars-gutter: @navbar-gutter;

@snackbar-v2-size: @font-size-base;
@snackbar-v2-padding: 6px 12px;
@snackbar-v2-border-radius: @border-radius-base;

@snackbar-v2-lg-size: @font-size-large;
@snackbar-v2-lg-padding: 8px 16px;
@snackbar-v2-lg-border-radius: @border-radius-base;
@snackbar-v2-lg-breakpoint: 720px;

@snackbar-v2-bg: #111;
@snackbar-v2-color: #ffff;

@snackbar-v2-success-bg: #16a34a;
@snackbar-v2-success-color: #ffff;

@snackbar-v2-warning-bg: #fef08a;
@snackbar-v2-warning-color: #000;

@snackbar-v2-danger-bg: #dc2626;
@snackbar-v2-danger-color: #ffff;

//== Overlay
//
@overlay-bg: @body-bg;
Expand Down
2 changes: 1 addition & 1 deletion misago/account/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class AccountPreferencesView(AccountSettingsFormView):
template_htmx_name = "misago/account/settings/preferences_partial.html"

success_message = pgettext_lazy(
"account settings preferences updated", "Preferences updated."
"account settings preferences updated", "Preferences updated"
)

def get_form_instance(self, request: HttpRequest) -> AccountPreferencesForm:
Expand Down
18 changes: 17 additions & 1 deletion misago/core/exceptionhandler.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponsePermanentRedirect, JsonResponse
from django.http import (
Http404,
HttpResponse,
HttpResponsePermanentRedirect,
JsonResponse,
)
from django.urls import reverse
from rest_framework.views import exception_handler as rest_exception_handler
from social_core.exceptions import SocialAuthBaseException
Expand Down Expand Up @@ -87,10 +92,21 @@ def get_exception_handler(exception):


def handle_misago_exception(request, exception):
if request.is_htmx and isinstance(exception, (Http404, PermissionDenied)):
return handle_htmx_exception(exception)

handler = get_exception_handler(exception)
return handler(request, exception)


def handle_htmx_exception(exception: Http404 | PermissionDenied) -> HttpResponse:
status = status = 404 if isinstance(exception, Http404) else 403
if not exception.args:
return HttpResponse(status=status)

return JsonResponse({"error": str(exception.args[0])}, status=status)


def handle_api_exception(exception, context):
response = rest_exception_handler(exception, context)
if response:
Expand Down
46 changes: 46 additions & 0 deletions misago/core/tests/test_exceptionhandlers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json

from django.core import exceptions as django_exceptions
from django.core.exceptions import PermissionDenied
from django.http import Http404
Expand Down Expand Up @@ -85,3 +87,47 @@ def test_unhandled_exception(self):
response = exceptionhandler.handle_api_exception(Http404(), None)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["detail"], "Not found.")


def test_misago_handler_handles_htmx_404_exception_with_message(rf):
request = rf.get("/", headers={"hx-request": "true"})
request.is_htmx = True

response = exceptionhandler.handle_misago_exception(
request, Http404("Thread not found")
)
assert response.status_code == 404
assert response["content-type"] == "application/json"
assert json.loads(response.content) == {"error": "Thread not found"}


def test_misago_handler_handles_htmx_404_exception_without_message(rf):
request = rf.get("/", headers={"hx-request": "true"})
request.is_htmx = True

response = exceptionhandler.handle_misago_exception(request, Http404())
assert response.status_code == 404
assert response["content-type"] == "text/html; charset=utf-8"
assert not response.content


def test_misago_handler_handles_htmx_403_exception_with_message(rf):
request = rf.get("/", headers={"hx-request": "true"})
request.is_htmx = True

response = exceptionhandler.handle_misago_exception(
request, PermissionDenied("Thread is closed")
)
assert response.status_code == 403
assert response["content-type"] == "application/json"
assert json.loads(response.content) == {"error": "Thread is closed"}


def test_misago_handler_handles_htmx_403_exception_without_message(rf):
request = rf.get("/", headers={"hx-request": "true"})
request.is_htmx = True

response = exceptionhandler.handle_misago_exception(request, PermissionDenied())
assert response.status_code == 403
assert response["content-type"] == "text/html; charset=utf-8"
assert not response.content
12 changes: 4 additions & 8 deletions misago/htmx/tests.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
from django.test import RequestFactory

from .request import is_request_htmx


def test_is_request_htmx_returns_true_for_htmx_request():
factory = RequestFactory()
request = factory.get("/", headers={"hx-request": "true"})
def test_is_request_htmx_returns_true_for_htmx_request(rf):
request = rf.get("/", headers={"hx-request": "true"})
assert is_request_htmx(request)


def test_is_request_htmx_returns_false_for_non_htmx_request():
factory = RequestFactory()
request = factory.get("/", headers={})
def test_is_request_htmx_returns_false_for_non_htmx_request(rf):
request = rf.get("/", headers={})
assert not is_request_htmx(request)
2 changes: 1 addition & 1 deletion misago/static/misago/css/misago.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion misago/static/misago/css/misago.css.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion misago/static/misago/js/misago.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion misago/static/misago/js/misago.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion misago/templates/misago/snackbars.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div id="misago-snackbars"{% if not inert %} hx-swap-oob="true"{% endif %}>
<div id="misago-snackbars"{% if not inert %} hx-swap-oob="innerHTML"{% endif %}>
{% for message in messages %}
{% if 'info' in message.tags %}
<div class="snackbar snackbar-info" role="alert">
Expand Down

0 comments on commit d11d869

Please sign in to comment.