-
-
Notifications
You must be signed in to change notification settings - Fork 8
JavaScript Frontend
To keep the project as simple and lightweight as possible, no JavaScript framework is used.
The frontend is written in plain JavaScript and has no dependencies for maximum longevity.
Since ES6, JavaScript has a module system. This makes it possible to handle dependencies easily and to structure the code.
Instead of having to load all the scripts in the correct order in the HTML file, the files (modules) containing relevant code can be imported in the script files themselves.
This way, the code from other JS files can be used easily everywhere in the frontend application by simply importing it the file.
Before a function or variable can be imported in another file, it has to be exported.
This is done by adding the export keyword in front of the function or variable declaration.
File: my-module.js
export const myVariable = 42;
export function myFunction() {
// ...
}The exported functions and variables can be imported in another file by using the import keyword.
IDEs like PHPStorm will automatically add the import statement when a function or variable from
another module is used.
File: main-module.js
import {myVariable, myFunction} from './my-module.js';
console.log(myVariable); // 42
myFunction();Only the main module file that imports other modules has to be loaded in the HTML file.
This is done with the usual <script> tag, but with the added attribute type="module".
<script type="module" src="main-module.js"></script>The browser will automatically cache this file and all the modules it requires, which means that when there is a change in one of the modules, the browser will not load the up-to-date version.
To fix this, a version number can be added to the file path as a query parameter.
<script type="module" src="main-module.js?version=1.0.0"></script>This project uses the template renderer to solve the assets versioning issue.
A template is responsible for loading the main module files as well as the other JS and CSS assets with the correct version number.
The path to the required module files is added to the template variables in an array at the top of the template file.
File: templates/template.html.php
// JS module
$this->addAttribute('jsModules', ['main-module.js',]);The Render JS and CSS Links
section shows how the <link> and <script> tags are rendered in the template.
Adding a version number to the module file that is required in the HTML file does not break the cache of the imported modules.
They are loaded by the scripts themselves, and the template renderer has nothing to do with the content of the modules.
JS Import Cache Busting explains how a version number can be added to the import statements programmatically.
With Ajax, the frontend can send and retrieve data from a server asynchronously (in the background) without interfering with the behavior of the loaded page.
There are two ways to send an Ajax request: XMLHttpRequest and fetch().
Initially Ajax was implemented using the XMLHttpRequest interface, but the
fetch()API is more suitable for modern web applications: it is more powerful, more flexible, and integrates better with fundamental web app technologies such as service workers.
Source: mdn web docs
Mozilla has an
excellent article
with an example on how to fetch data using the fetch() API.
Below is an example of a fetch() request that sends a JSON PUT request to the server.
fetch('url', {
method: 'PUT',
headers: {"Content-type": "application/json"},
body: JSON.stringify({key: 'value'})
}).then(response => {
if (!response.ok) {
// Throw error so it can be caught in catch block
throw new Error('Response status: ' + response.status);
}
// Returns promise which resolves to the response body as JSON
return response.json();
});This project has functions for CRUD operations that send the request to the server with the correct headers and return a promise that resolves to the json data.
If the request fails, the fail-handler.js displays a flash message to the user
with the appropriate error message.
Then, an exception is thrown so that it can be caught in a catch block.
The catch block is not implemented in the functions that make the Ajax request, so that the calling function can implement it in case there is some logic to be executed when the request fails.
The fail handler goes through the response and informs the user about the error.
The behaviour of the fail handler and the information in the flash message differs depending on the status code. Here is a list of common status codes:
-
401 Unauthorized: The user is not authenticated. The user is redirected to the login page with the redirect back url in the query string. -
403 Forbidden: The user is authenticated but does not have the required privileges. The flash message informs the user about the missing privileges. -
404 Not Found: The requested resource was not found. The URL is invalid. -
422 Unprocessable Entity: The request body contains invalid form data. The error for each field is displayed in the form and no flash message is displayed. -
500 Internal Server Error: There was an error on the server.
To get data from the server, the fetchData() function can be used.
It sends a GET request to the server and returns a promise that resolves to the response body as JSON.
The only parameter is the route after the base path (e.g. users/1).
fetchData('users?param=1')
.then(jsonResponse => {
// Code
})
.catch(error => {
console.error(error);
});File: public/assets/general/ajax/fetch-data.js
/**
* Sends a GET request and returns result in promise
*
* @param {string} route the part after base path (e.g. 'users/1'). Query params have to be added with ?param=value
* @return {Promise<details>}
*/
export function fetchData(route) {
return fetch(basePath + route, {method: 'GET', headers: {"Content-type": "application/json"}})
.then(async response => {
if (!response.ok) {
await handleFail(response);
throw response;
}
return response.json();
});
// Without catch block to let the calling function implement it
}The submit update function sends a PUT request to the server with the given form data.
- The first parameter is an object with the form field names as keys and the values as values
- The second parameter is the route after the base path (e.g.
users/1) - The third parameter is optional for field id of the field that should display the
validation error message in case the request fails with a
422 Unprocessable Entitystatus code.
This function is mainly used to submit one value at a time. It only supports the validation error placement for one field.
More complex forms in modal boxes use the
submitModalForm() function.
let select = fieldContainer.querySelector('select');
select.addEventListener('change', () => {
submitUpdate(
// In square brackets to use the value of the variable as key
{[select.name]: select.value},
`users/1`,
).then(responseJson => {
// Code
}).catch(error => {
console.error(error);
});
});File: public/assets/general/ajax/submit-update-data.js
/**
* Send PUT update request.
* Fail handled by handleFail() method which supports forms
* On success validation errors are removed if there were any and response json returned
*
* @param {object} formFieldsAndValues {field: value} e.g. {[input.name]: input.value}
* @param {string} route after base path (e.g. clients/1)
* @param domFieldId field id to display the validation error message for the correct field
* @return Promise with as content server response as JSON
*/
export function submitUpdate(formFieldsAndValues, route, domFieldId = null) {
return fetch(basePath + route, {
method: 'PUT',
headers: {"Content-type": "application/json"},
body: JSON.stringify(formFieldsAndValues)
})
.then(async response => {
if (!response.ok) {
await handleFail(response, domFieldId);
throw new Error('Response status not 2xx. Status: ' + response.status);
}
// Remove validation error messages if there are any
removeValidationErrorMessages();
return response.json();
});
}In this project currently, all forms except the login form are in modal boxes.
The process of submitting a form in a modal box is always the same:
- Check if the form is valid
- Serialize form data
- Disable form fields while request is being sent
- Send request to server
- Close modal box on success / enable form fields and show errors on fail
The submitModalForm() function does all of this and returns a promise that resolves to
the response body as JSON.
These are the parameters:
- HTML id of the form
- Route after the base path (e.g.
users) - HTTP method (e.g.
POSTorPUT)
submitModalForm('create-user-modal-form', 'users', 'POST')
.then((responseJson) => {
// Inform user about success
displayFlashMessage('success', translated['User created successfully']);
// Reload user list
loadUserList();
}).catch(error => {
console.error(error);
})File: public/assets/general/ajax/submit-modal-form.js
/**
* Retrieves form data, checks form validity, disables form, submits modal form and closes it on success
*
* @param {string} modalFormId
* @param {string} moduleRoute POST module route like "users" or "clients"
* @param {string} httpMethod POST or PUT
* @return Promise with as content server response as JSON
*/
export function submitModalForm(
modalFormId, moduleRoute, httpMethod
) {
// Check if form content is valid (frontend validation)
let modalForm = document.getElementById(modalFormId);
if (modalForm.checkValidity() === false) {
// If not valid, report to user and return void
modalForm.reportValidity();
// If nothing is returned "then()" will not exist; add "?" before the call: submitModalForm()?.then()
return;
}
// Serialize form data before disabling form elements
let formData = getFormData(modalForm);
// Disable form to indicate that the request is made
// This has to be after getting the form data as FormData() doesn't consider disabled fields
toggleEnableDisableForm(modalFormId);
return fetch(basePath + moduleRoute, {
method: httpMethod,
headers: {"Content-type": "application/json"},
body: JSON.stringify(formData)
})
.then(async response => {
if (!response.ok) {
// Re enable form if request is not successful
toggleEnableDisableForm(modalFormId);
// Default fail handler
await handleFail(response);
// Throw error so it can be caught in catch block
throw new Error('Response status: ' + response.status);
}
closeModal();
return response.json();
});
}The getFormData() and toggleEnableDisableForm() functions are
in implemented in public/assets/general/page-component/modal/modal-form.js.
To delete a resource, the submitDelete() function can be used.
It accepts the route after the base path (e.g. users/1) as parameter and returns
a promise that resolves to the response body as JSON.
document.querySelector('#delete-client-btn')?.addEventListener('click', () => {
const title = translated['Are you sure that you want to delete this client?'];
createAlertModal(title, '', () => {
submitDelete(`clients/1`).then(() => {
// Redirect to client list page if request was successful
location.href = `clients/list`;
});
});
});File: public/assets/general/ajax/submit-delete-request.js
/**
* Send DELETE request.
*
* @param {string} route after base path (e.g. 'users/1')
* @return Promise with as content server response as JSON
*/
export function submitDelete(route) {
return fetch(basePath + route, {
method: 'DELETE',
headers: {"Content-type": "application/json"}
})
.then(async response => {
if (!response.ok) {
await handleFail(response);
// Throw error so it can be caught in catch block
throw new Error('Response status: ' + response.status);
}
return response.json();
});
}I asked myself what would be the most user-friendly way to edit a text field that is part of the information of the page such as the name of a user and their email in the user profile page.
The user should be able to edit the field directly on the page with minimal UI changes.
My first thoughts were to display an "edit" icon next to each field that can be edited.
When the user clicks on the icon, the text span is replaced by an input field
and the edit icon is replaced by a save icon.
I liked the idea but disliked the UI change from the span, heading or another HTML element
to the input field.
The answer seemed to be the
contenteditable
attribute.
It makes any HTML element editable keeping exactly the same style.
The user should still be able to see if a field is currently editable or not, so border and slight background color change are needed, but the text style doesn't change.
The edit button becomes visible when the mouse hovers over the field
and a double click anywhere on the field also makes it editable.
On mobile, the edit button is always visible as there is no mouse and the user
might not know that the field is editable upon tapping on it.
The examples below use the PHP-View template renderer for data population and authorization checks but the same principle applies to any other template renderer.
The by default supported editable elements are span and h1.
They must be wrapped in a container div with the
class contenteditable-field-container and the attribute data-field-element
with the name of the element that should be editable ("span" or "h1").
Then, the edit icon (class contenteditable-edit-icon) is added before the editable element as its style is changed
on hover of the edit icon and only next siblings can be styled in CSS (not previous).
The editable element itself has a data-name attribute which acts like the name
or an input element.
This is the key that is being sent to the server when the field is updated.
It can also have data attributes for frontend validation.
Currently supported are data-required, data-minlength and data-maxlength.
When there is no content, the hoverable area to display an edit icon is quite small.
Therefore, a non-breaking space should be added as content if the field is empty.
<?php // PHP template ?>
<div class="contenteditable-field-container user-field-value-container" data-field-element="span">
<?php // Add edit icon to DOM if user has update privilege for this field
if (str_contains($user->generalPrivilege, 'U')) { ?>
<!-- Img has to be before title because only the next sibling can be styled in css -->
<img src="assets/general/general-img/material-edit-icon.svg"
class="contenteditable-edit-icon cursor-pointer"
alt="Edit"
id="edit-email-btn">
<?php
} ?>
<span spellcheck="false" data-name="email" data-maxlength="254"
><?= !empty($user->email) ? html($user->email) : ' ' ?></span>
</div>Headings are a bit more complex because they have a bottom margin which doesn't look
good when the text is wrapped.
Additionally, in the project use-case 2 editable headings are used next to each other
which needs a little more styling to not mess up hoverable area for the edit icon.
This is why each editable heading is wrapped in a div with class
partial-contenteditable-heading-div. These divs are then wrapped in a container
outer-contenteditable-heading-container which contains the bottom margin.
<?php // PHP template ?>
<div id="outer-contenteditable-heading-container" data-deleted="<?= $clientAggregate->deletedAt ? 1 : 0 ?>">
<div class="partial-contenteditable-heading-div contenteditable-field-container" data-field-element="h1">
<?php // Add edit icon to DOM if user has update privilege for this field
if (str_contains($clientAggregate->generalPrivilege, 'U')) { ?>
<!-- Img has to be before title because only the next sibling can be styled in css -->
<img src="assets/general/general-img/material-edit-icon.svg"
class="contenteditable-edit-icon cursor-pointer"
alt="Edit"
id="edit-first-name-btn">
<?php
} ?>
<h1 data-name="first_name" data-minlength="2" data-maxlength="100" spellcheck="false"><?=
!empty($clientAggregate->firstName) ? html($clientAggregate->firstName) : ' ' ?></h1>
</div>
<!-- Other editable heading... -->
</div>The event listeners for the edit icon and double-click on the editable element are added in a JavaScript file loaded with the page:
// Null safe operator `?` as edit icon doesn't exist if not privileged
// Heading
document.querySelector('#edit-first-name-btn')?.addEventListener('click', makeUserFieldEditable);
document.querySelector('h1[data-name="first_name"]')?.addEventListener('dblclick', makeUserFieldEditable);
// Span
document.querySelector('#edit-email-btn')?.addEventListener('click', makeUserFieldEditable);
document.querySelector('[data-name="email"]')?.addEventListener('dblclick', makeUserFieldEditable);This is the event handler for the edit icon and the double click on the editable element.
It calls the general function makeFieldEditable and adds the focusout event listener
to save the value.
export function makeUserFieldEditable() {
// "this" is the edit icon or the field itself
let field = this.parentNode.querySelector(this.parentNode.dataset.fieldElement);
// Make field editable, add save button, add enter key press event listener and focus it
makeFieldEditable(field);
// Save field value on focus out
// The save btn event listener is not needed as by clicking on the button the focus
// goes out of the edited field
field.addEventListener('focusout', validateContentEditableAndSaveUserValue);
}Upon pressing the enter key or clicking outside the editable field, the field value
should be saved.
This is done by the validateContentEditableAndSaveUserValue and
saveUserValueAndDisableContentEditable functions.
The first function calls the general function contentEditableFieldValueIsValid which
validates the field content and displays an error message if it's invalid.
If the contents passed the frontend validation, any error that might have been displayed
is removed, and the function saveUserValueAndDisableContentEditable is called.
If the frontend-validation fails, the focus is locked on it until the input is valid.
function validateContentEditableAndSaveUserValue() {
// "this" is the field
if (contentEditableFieldValueIsValid(this)) {
// Remove validation error messages if any
removeValidationErrorMessages();
// Disable contenteditable and save user value
saveUserValueAndDisableContentEditable(this);
} else {
// Lock the focus on the field until the input is valid
this.focus();
}
}The second function calls the general function disableEditableField which sets
the contenteditable attribute to false and saves the value by sending a
PUT request to the server.
function saveUserValueAndDisableContentEditable(field) {
disableEditableField(field);
let userId = document.getElementById('user-id').value;
let submitValue = field.textContent.trim();
submitUpdate(
{[field.dataset.name]: submitValue},
`users/${userId}`
).then(responseJson => {
// Field disabled before save request and re enabled on error
}).catch(errorMsg => {
// If request not successful, make the field editable again and focus it
makeFieldEditable(field);
});
}The "usage example" section above shows a basic example of how these contenteditable
functions can be used.
A field could also be a button opening a link or a <select> field.
The makeClientFieldEditable function of the
public/assets/client/update/client-update-contenteditable.js
file contains an
example of a span that is an <a> tag when not editable.
And makeFieldSelectValueEditable in
public/assets/client/update/client-update-dropdown.js
showcases and example of a field value that
can be changed via a <select> dropdown.
The functions to enable, disable and validate field values are the same for all
the different pages that use contenteditable fields.
import {displayValidationErrorMessage} from "../../validation/form-validation.js";
/**
* Make field value editable, add save button and focus it.
*/
export function makeFieldEditable(field) {
let editIcon = field.parentNode.querySelector('.contenteditable-edit-icon');
let fieldContainer = field.parentNode;
// Hide edit icon, make field editable, focus it and remove if empty
editIcon.style.display = 'none';
field.contentEditable = 'true';
field.focus();
if (field.innerHTML === ' ') {
field.innerHTML = '';
}
// Slick would be to replace the word "edit" of the edit icon with "save" for the save button but that puts a dependency
// on the id name that can be avoided when just appending a word
let saveBtnId = editIcon.id + '-save';
// Add save button if not already existing but hidden until an input is made
if (document.querySelector('#' + saveBtnId) === null) {
fieldContainer.insertAdjacentHTML('afterbegin', `<img src="assets/general/general-img/checkmark.svg"
class="contenteditable-save-icon cursor-pointer" alt="Save"
id="${saveBtnId}" style="display: none">`);
}
let saveBtn = document.getElementById(saveBtnId);
// Save on enter key press
fieldContainer.addEventListener('keypress', function (e) {
// Save on enter keypress or ctrl enter / cmd enter
if (e.key === 'Enter' || (e.ctrlKey || e.metaKey) && (e.keyCode === 13 || e.keyCode === 10)) {
// Prevent new line on enter key press
e.preventDefault();
// Triggers focusout event that is caught in event listener and saves client value
// field.contentEditable = 'false';
field.dispatchEvent(new Event('focusout'));
}
});
// Display save button after the first input
fieldContainer.addEventListener('input', () => {
if (saveBtn.style.display === 'none') {
saveBtn.style.display = 'inline-block';
}
});
}
export function disableEditableField(field) {
let fieldContainer = field.parentNode;
// If empty submit value successfully submitted, and it doesn't have data-hide-if-empty="true",
// add a for it to be visible on hover and edited later
if (field.textContent.trim() === '' && fieldContainer.dataset.hideIfEmpty !== 'true') {
fieldContainer.querySelector(fieldContainer.dataset.fieldElement).innerHTML = ' ';
}
field.contentEditable = 'false';
fieldContainer.querySelector('.contenteditable-edit-icon').style.display = null; // Default display
// I don't know why but the focusout event is triggered multiple times when clicking on the edit icon again
let saveIcon = fieldContainer.querySelector('.contenteditable-save-icon');
// Only remove it if it exists to prevent error in case field was unchanged and save icon not displayed
saveIcon?.remove();
}
/**
* Frontend validation of contenteditable field
* and request to update value if valid.
*
* @return boolean
*/
export function contentEditableFieldValueIsValid(field) {
let textContent = field.textContent.trim();
let fieldName = field.dataset.name;
let required = field.dataset.required;
if (required !== undefined && required === 'true' && textContent.length === 0) {
displayValidationErrorMessage(fieldName, 'Required');
return false;
}
// Check that length is either 0 or more than given minlength (0 is checked with required above)
let minLength = field.dataset.minlength;
if (minLength !== undefined && (textContent.length < parseInt(minLength) && textContent.length !== 0)) {
displayValidationErrorMessage(fieldName, 'Minimum length is' + ' ' + minLength);
return false;
}
// Check that length is either 0 or more than given maxlength
let maxLength = field.dataset.maxlength;
if (maxLength !== undefined && (textContent.length > parseInt(maxLength) && textContent.length !== 0)) {
displayValidationErrorMessage(fieldName, 'Maximum length is' + ' ' + maxLength);
return false;
}
// If no validation error was found
return true;
}/* mobile first min-width sets base and content is adapted to computers. */
@media (min-width: 100px) {
#outer-contenteditable-heading-container {
margin-bottom: 25px;
}
#outer-contenteditable-heading-container h1 {
display: inline-block;
/*Remove bottom margin on h1 and put it on h1 container in case first and last name wrap*/
margin-bottom: 0;
padding: 5px 5px 5px 3px;
overflow-wrap: anywhere;
white-space: break-spaces;
}
#outer-contenteditable-heading-container[data-deleted="1"] h1 {
color: orangered;
}
/*Clear float*/
#outer-contenteditable-heading-container::after {
content: "";
clear: both;
display: table;
}
/*Div containing first or last name header*/
.partial-contenteditable-heading-div {
float: left; /* Prevent not hoverable whitespace between partial header divs*/
}
.contenteditable-field-container {
position: relative;
display: inline-block;
padding-right: 15px;
}
.contenteditable-edit-icon, .contenteditable-save-icon {
display: none;
position: absolute;
width: 20px;
padding: 2px;
border-radius: 99px;
border: 1px solid black; /* The actual color is set by the filter*/
/* The filter here is so that the background is always correct (even if there is no filter otherwise) */
/*filter: invert(20%) sepia(9%) saturate(2106%) hue-rotate(172deg) brightness(93%) contrast(86%);*/
filter: var(--primary-color-accent-filter);
background: rgba(93, 87, 29, 0.18); /* This is a recreation of this color #d8dee8; with the filter */
right: -7px;
top: -3px;
z-index: 1;
}
.contenteditable-field-container:hover .contenteditable-edit-icon, .always-displayed-icon {
display: inline-block;
}
/* Style next sibling https://stackoverflow.com/a/12574836/9013718 (~ works better than + actually as it doesn't
have to be immediate next sibling. LanguageTool extension puts a <lt-highlighter> element before h1) */
/* Display outline on h1 when hover on edit icon and when contenteditable is true */
.contenteditable-edit-icon:hover ~ h1, .partial-contenteditable-heading-div h1[contenteditable="true"] {
outline: 3px solid var(--primary-color);
border-radius: 10px;
background: var(--background-accent-color);
}
/* Display outline on span element */
.contenteditable-edit-icon:hover ~ span, .contenteditable-field-container span[contenteditable="true"] {
outline: 2px solid var(--primary-color);
border-radius: 5px;
background: var(--background-accent-color);
}
.contenteditable-placeholder[contenteditable=true]:empty:before {
content: attr(data-placeholder);
color: gray;
}
}Slim app basics
- Composer
- Web Server config and Bootstrapping
- Dependency Injection
- Configuration
- Routing
- Middleware
- Architecture
- Single Responsibility Principle
- Action
- Domain
- Repository and Query Builder
Features
- Logging
- Validation
- Session and Flash
- Authentication
- Authorization
- Translations
- Mailing
- Console commands
- Database migrations
- Error handling
- Security
- API endpoint
- GitHub Actions
- Scrutinizer
- Coding standards fixer
- PHPStan static code analysis
Testing
Frontend
Other