Merge branch 'aa-framework-js-functions' into 'master'

[ADD] JS functions to the AA framework

See merge request allianceauth/allianceauth!1747
This commit is contained in:
Ariel Rin 2025-08-14 02:09:10 +00:00
commit 3c1bae463e
9 changed files with 466 additions and 64 deletions

View File

@ -15,6 +15,10 @@
ul#nav-right:has(li) + ul#nav-right-character-control > li:first-child {
display: list-item !important;
}
form.is-submitting button[type="submit"] {
cursor: not-allowed;
}
}
@media all and (max-width: 991px) {

View File

@ -0,0 +1,300 @@
/**
* Functions and utilities for the Alliance Auth framework.
*/
/* jshint -W097 */
'use strict';
/**
* Checks if the given item is an array.
*
* @usage
* ```javascript
* if (isArray(someVariable)) {
* console.log('This is an array');
* } else {
* console.log('This is not an array');
* }
* ```
*
* @param {*} item - The item to check.
* @returns {boolean} True if the item is an array, false otherwise.
*/
const isArray = (item) => {
return Array.isArray(item);
};
/**
* Checks if the given item is a plain object, excluding arrays and dates.
*
* @usage
* ```javascript
* if (isObject(someVariable)) {
* console.log('This is a plain object');
* } else {
* console.log('This is not a plain object');
* }
* ```
*
* @param {*} item - The item to check.
* @returns {boolean} True if the item is a plain object, false otherwise.
*/
const isObject = (item) => {
return (
item && typeof item === 'object' && !isArray(item) && !(item instanceof Date)
);
};
/**
* Fetch data from an ajax URL
*
* Do not call this function directly, use `fetchGet` or `fetchPost` instead.
*
* @param {string} url The URL to fetch data from
* @param {string} method The HTTP method to use for the request (default: 'get')
* @param {string|null} csrfToken The CSRF token to include in the request headers (default: null)
* @param {string|null} payload The payload (JSON|Object) to send with the request (default: null)
* @param {boolean} responseIsJson Whether the response is expected to be JSON or not (default: true)
* @returns {Promise<string>} The fetched data
* @throws {Error} Throws an error when:
* - The method is not valid (only `get` and `post` are allowed).
* - The CSRF token is required but not provided for POST requests.
* - The payload is not an object when using POST method.
* - The response status is not OK (HTTP 200-299).
* - There is a network error or if the response cannot be parsed as JSON.
*/
const _fetchAjaxData = async ({
url,
method = 'get',
csrfToken = null,
payload = null,
responseIsJson = true
}) => {
const normalizedMethod = method.toLowerCase();
// Validate the method
const validMethods = ['get', 'post'];
if (!validMethods.includes(normalizedMethod)) {
throw new Error(`Invalid method: ${method}. Valid methods are: get, post`);
}
const headers = {};
// Set headers based on response type
if (responseIsJson) {
headers['Accept'] = 'application/json'; // jshint ignore:line
headers['Content-Type'] = 'application/json';
}
let requestUrl = url;
let body = null;
if (normalizedMethod === 'post') {
if (!csrfToken) {
throw new Error('CSRF token is required for POST requests');
}
headers['X-CSRFToken'] = csrfToken;
if (payload !== null && !isObject(payload)) {
throw new Error('Payload must be an object when using POST method');
}
body = payload ? JSON.stringify(payload) : null;
} else if (normalizedMethod === 'get' && payload) {
const queryParams = new URLSearchParams(payload).toString(); // jshint ignore:line
requestUrl += (url.includes('?') ? '&' : '?') + queryParams;
}
/**
* Throws an error with a formatted message.
*
* @param {Response} response The error object containing the message to throw.
*/
const throwHTTPStatusError = (response) => {
throw new Error(`Error: ${response.status} - ${response.statusText}`);
};
try {
const response = await fetch(requestUrl, {
method: method.toUpperCase(),
headers: headers,
body: body
});
/**
* Throws an error if the response status is not OK (HTTP 200-299).
* This is used to handle HTTP errors gracefully.
*/
if (!response.ok) {
throwHTTPStatusError(response);
}
return responseIsJson ? await response.json() : await response.text();
} catch (error) {
// Log the error message to the console
console.log(`Error: ${error.message}`);
throw error;
}
};
/**
* Fetch data from an ajax URL using the GET method.
* This function is a wrapper around _fetchAjaxData to simplify GET requests.
*
* @usage
* ```javascript
* fetchGet({
* url: url,
* responseIsJson: false
* }).then((data) => {
* // Process the fetched data
* }).catch((error) => {
* console.error(`Error: ${error.message}`);
*
* // Handle the error appropriately
* });
* ```
*
* @param {string} url The URL to fetch data from
* @param {string|null} payload The payload (JSON) to send with the request (default: null)
* @param {boolean} responseIsJson Whether the response is expected to be JSON or not (default: true)
* @return {Promise<string>} The fetched data
*/
const fetchGet = async ({
url,
payload = null,
responseIsJson = true
}) => {
return await _fetchAjaxData({
url: url,
method: 'get',
payload: payload,
responseIsJson: responseIsJson
});
};
/**
* Fetch data from an ajax URL using the POST method.
* This function is a wrapper around _fetchAjaxData to simplify POST requests.
* It requires a CSRF token for security purposes.
*
* @usage
* ```javascript
* fetchPost({
* url: url,
* csrfToken: csrfToken,
* payload: {
* key: 'value',
* anotherKey: 'anotherValue'
* },
* responseIsJson: true
* }).then((data) => {
* // Process the fetched data
* }).catch((error) => {
* console.error(`Error: ${error.message}`);
*
* // Handle the error appropriately
* });
* ```
*
* @param {string} url The URL to fetch data from
* @param {string|null} csrfToken The CSRF token to include in the request headers (default: null)
* @param {string|null} payload The payload (JSON) to send with the request (default: null)
* @param {boolean} responseIsJson Whether the response is expected to be JSON or not (default: true)
* @return {Promise<string>} The fetched data
*/
const fetchPost = async ({
url,
csrfToken,
payload = null,
responseIsJson = true
}) => {
return await _fetchAjaxData({
url: url,
method: 'post',
csrfToken: csrfToken,
payload: payload,
responseIsJson: responseIsJson
});
};
/**
* Recursively merges properties from source objects into a target object. If a property at the current level is an object,
* and both target and source have it, the property is merged. Otherwise, the source property overwrites the target property.
* This function does not modify the source objects and prevents prototype pollution by not allowing __proto__, constructor,
* and prototype property names.
*
* @usage
* ```javascript
* const target = {a: 1, b: {c: 2}};
* const source1 = {b: {d: 3}, e: 4 };
* const source2 = {a: 5, b: {c: 6}};
*
* const merged = objectDeepMerge(target, source1, source2);
*
* console.log(merged); // {a: 5, b: {c: 6, d: 3}, e: 4}
* ```
*
* @param {Object} target The target object to merge properties into.
* @param {...Object} sources One or more source objects from which to merge properties.
* @returns {Object} The target object after merging properties from sources.
*/
function objectDeepMerge (target, ...sources) {
if (!sources.length) {
return target;
}
// Iterate through each source object without modifying the `sources` array.
sources.forEach(source => {
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue; // Skip potentially dangerous keys to prevent prototype pollution.
}
if (!target[key] || !isObject(target[key])) {
target[key] = {};
}
objectDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
});
return target;
}
/**
* When the document is ready
*/
$(document).ready(() => {
/**
* Prevent double form submits by adding a class to the form
* when it is submitted.
*
* This class can be used to show a visual indicator that the form is being
* submitted, such as a spinner.
*
* This is useful to prevent users from double-clicking the submit button
* and submitting the form multiple times.
*/
document.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', (e) => {
// Prevent if already submitting
if (form.classList.contains('is-submitting')) {
e.preventDefault();
}
// Add class to hook our visual indicator on
form.classList.add('is-submitting');
});
});
});

View File

@ -1,4 +1,4 @@
/* global notificationUpdateSettings */
/* global notificationUpdateSettings, fetchGet */
/**
* This script refreshed the notification icon in the top menu
@ -19,22 +19,9 @@ $(() => {
* Update the notification icon in the top menu
*/
const updateNotificationIcon = () => {
fetch(userNotificationCountViewUrl)
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error('Something went wrong');
})
.then((responseJson) => {
const unreadCount = responseJson.unread_count;
if (unreadCount > 0) {
elementNotificationIcon.addClass('text-danger');
} else {
elementNotificationIcon.removeClass('text-danger');
}
fetchGet({url: userNotificationCountViewUrl})
.then((data) => {
elementNotificationIcon.toggleClass('text-danger', data.unread_count > 0);
})
.catch((error) => {
console.log(`Failed to load HTMl to render notifications item. Error: ${error.message}`);

View File

@ -8,30 +8,22 @@
</div>
<script>
const elemCard = document.getElementById("esi-alert");
const elemMessage = document.getElementById("esi-data");
const elemCode = document.getElementById("esi-code");
const elemCard = document.getElementById('esi-alert');
const elemMessage = document.getElementById('esi-data');
const elemCode = document.getElementById('esi-code');
fetch('{% url "authentication:esi_check" %}')
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error("Something went wrong");
})
.then((responseJson) => {
console.log("ESI Check: ", JSON.stringify(responseJson, null, 2));
fetchGet({url: '{% url "authentication:esi_check" %}'})
.then((data) => {
console.log('ESI Check: ', JSON.stringify(data, null, 2));
const status = responseJson.status;
if (status !== 200) {
elemCode.textContent = status
elemMessage.textContent = responseJson.data.error;
new bootstrap.Collapse(elemCard, {
toggle: true
})
if (data.status !== 200) {
elemCode.textContent = data.status;
elemMessage.textContent = data.data.error;
new bootstrap.Collapse(elemCard, {toggle: true});
}
})
.catch((error) => {
console.log(error);
console.error('Error fetching ESI check:', error);
});
</script>

View File

@ -136,34 +136,24 @@
</div>
<script>
const elemRunning = document.getElementById("task-counts");
const elemQueued = document.getElementById("queued-tasks-count");
const elemRunning = document.getElementById('task-counts');
const elemQueued = document.getElementById('queued-tasks-count');
fetch('{% url "authentication:task_counts" %}')
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error("Something went wrong");
})
.then((responseJson) => {
const running = responseJson.tasks_running;
if (running == null) {
elemRunning.textContent = "N/A";
} else {
elemRunning.textContent = running.toLocaleString();
}
fetchGet({url: '{% url "authentication:task_counts" %}'})
.then((data) => {
const running = data.tasks_running;
const queued = data.tasks_queued;
const queued = responseJson.tasks_queued;
if (queued == null) {
elemQueued.textContent = "N/A";
} else {
elemQueued.textContent = queued.toLocaleString();
}
const updateTaskCount = (element, value) => {
element.textContent = value == null ? 'N/A' : value.toLocaleString();
};
updateTaskCount(elemRunning, running);
updateTaskCount(elemQueued, queued);
})
.catch((error) => {
console.log(error);
elemRunning.textContent = "ERROR";
elemQueued.textContent = "ERROR";
console.error('Error fetching task queue:', error);
[elemRunning, elemQueued].forEach(elem => elem.textContent = 'ERROR');
});
</script>

View File

@ -23,6 +23,9 @@
{% include 'bundles/fontawesome.html' %}
{% include 'bundles/auth-framework-css.html' %}
{% include 'bundles/jquery-js.html' %}
{% include 'bundles/auth-framework-js.html' %}
<style>
@media all {
.nav-padding {
@ -137,8 +140,6 @@
})();
</script>
{% include 'bundles/jquery-js.html' %}
{% theme_js %}
{% if user.is_authenticated %}

View File

@ -0,0 +1,3 @@
{% load sri %}
{% sri_static 'allianceauth/framework/js/auth-framework.js' %}

View File

@ -13,6 +13,7 @@ The Alliance Auth framework is split into several submodules, each of which is d
framework/api
framework/css
framework/js
framework/templates
framework/svg-sprite
:::

View File

@ -0,0 +1,124 @@
# JavaScript Framework
This contains some simple JavaScript functions that are used throughout Alliance Auth,
so they can be used by community app developers as well.
The JS file is already loaded in the base template, so it is globally available.
## Functions
The following functions are available in the JavaScript framework:
### isArray()
Checks if the given value is an array.
Usage:
```javascript
/* global isArray */
if (isArray(someVariable)) {
console.log('This is an array');
} else {
console.log('This is not an array');
}
```
### isObject()
Checks if the given value is an object.
Usage:
```javascript
/* global isObject */
if (isObject(someVariable)) {
console.log('This is a plain object');
} else {
console.log('This is not a plain object');
}
```
### fetchGet()
Performs a GET request to the given URL and returns a Promise that resolves with the response data.
Usage:
```javascript
/* global fetchGet */
fetchGet({
url: url,
responseIsJson: false
}).then((data) => {
// Process the fetched data
}).catch((error) => {
console.error(`Error: ${error.message}`);
// Handle the error appropriately
});
```
#### fetchGet() Parameters
- `url`: The URL to fetch data from.
- `payload`: Optional data to send with the request. Can be an object or a string.
- `responseIsJson`: Optional boolean indicating if the response should be parsed as JSON (default is `true`).
### fetchPost()
Performs a POST request to the given URL with the provided data and returns a Promise that resolves with the response data.
Usage:
```javascript
/* global fetchPost */
fetchPost({
url: url,
csrfToken: csrfToken,
payload: {
key: 'value',
anotherKey: 'anotherValue'
},
responseIsJson: true
}).then((data) => {
// Process the fetched data
}).catch((error) => {
console.error(`Error: ${error.message}`);
// Handle the error appropriately
});
```
#### fetchPost() Parameters
- `url`: The URL to send the POST request to.
- `csrfToken`: The CSRF token to include in the request headers.
- `payload`: The data as JS object to send with the request.
- `responseIsJson`: Optional boolean indicating if the response should be parsed as JSON (default is `true`).
### objectDeepMerge()
Recursively merges properties from source objects into a target object. If a property at the current level is an object,
and both target and source have it, the property is merged. Otherwise, the source property overwrites the target property.
This function does not modify the source objects and prevents prototype pollution by not allowing `__proto__`, `constructor`,
and `prototype` property names.
Usage:
```javascript
/* global objectDeepMerge */
const target = {a: 1, b: {c: 2}};
const source1 = {b: {d: 3}, e: 4 };
const source2 = {a: 5, b: {c: 6}};
const merged = objectDeepMerge(target, source1, source2);
console.log(merged); // {a: 5, b: {c: 6, d: 3}, e: 4}
```