JavaScript Frontend
Table of contents
Loading...Introduction
To keep the slim-example-project as simple and lightweight as possible, it is not dependent on any JavaScript framework or library.
The frontend is built with vanilla JavaScript and ES6 modules.
ES6 Modules
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.
That way, the code from other JS files can be accessed easily everywhere in the frontend application by simply importing the function or class from that other file.
Exporting functions, variables, and classes
Before a function or variable can be imported into another file, it has to be exported first.
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() {
console.log('Hello from myFunction');
}
export class MyClass {
constructor() {
console.log('Hello from MyClass');
}
}
Importing modules
The exported elements can be imported by using the import keyword.
IDEs like PHPStorm will automatically add the import statement when a function, variable
or class from another module is used.
File: main-module.js
import {myVariable, myFunction, MyClass} from './my-module.js';
console.log(myVariable); // 42
myFunction(); // Hello from myFunctions
new MyClass(); // Hello from MyClass
Loading modules in HTML
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>
Loading modules with versioning
The browser will automatically cache the file added via the <script> tag 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 the caching issue for the main module, 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>
To facilitate the versioning of the modules added via HTML, the slim-example-project uses the template renderer to add the assets with the version number.
The templates are responsible for loading the main module files as well as the other JS and CSS assets.
The path to the required module 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',]);
Read more about this in Template Rendering - Asset handling.
JS module cache busting
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.
Frontend Concept Overview
Folder Structure
Assets mirror the backend module structure.
Each module gets its own top-level folder under public/assets/, and within it, subfolders per feature/use-case:
public/assets/
├── general/ # Shared utilities, components, styles, images, fonts
│ ├── ajax/ # All HTTP request helpers
│ ├── event-handler/ # Reusable event utilities
│ ├── general-css/ # Always-loaded global CSS (colors, layout, general, header, page-elements)
│ ├── general-font/ # Font files
│ ├── general-img/ # Shared images and icons
│ ├── general-js/ # Core utilities (functions.js etc.)
│ ├── page-behaviour/ # Theme, table-row-update, etc.
│ ├── page-component/ # Reusable UI components (modal, combobox, pagination, skeleton-loader, etc.)
│ ├── template/ # Shared HTML templates
│ └── validation/ # Client-side validation helpers
│
├── {module}/ # e.g. customer/, order/, product/, user/
│ ├── img/ # Module-specific images
│ ├── list/ # List page JS + CSS
│ ├── create/ # Create page JS + CSS
│ ├── read/ # Read/detail page JS + CSS
│ ├── update/ # Update page JS + CSS
│ ├── modal-form/ # Shared modal form HTML template + handlers
│ └── {other-feature}/ # e.g. note/, util/, product-group/
Naming Logic
Files
| Pattern | Example |
|---|---|
{module}-{feature}-main.js |
customer-list-main.js, order-read-main.js |
{module}-{feature}-loading.js |
customer-list-loading.js, order-read-loading.js |
{module}-{feature}-dom-population.js |
customer-list-dom-population.js, order-read-populate-dom.js |
{module}-{feature}-skeleton-loader.js / .css |
customer-list-skeleton-loader.js |
{module}-{feature}.css |
customer-list.css, order-read.css |
{module}-modal-form.html.js |
customer-modal-form.html.js, product-modal-form.html.js |
{module}-{feature}-table-rows.html.js |
customer-list-table-rows.html.js |
The .html.js suffix signals a JS file that returns HTML strings (template functions), not logic.
CSS
- Global CSS (always loaded via layout):
colors.css,general.css,layout.css,header.css,page-elements.css - Per-page CSS (added in the template only when needed):
form.css,form-modal.css,result-table.css,skeleton-loader.css,combobox.css, etc. - Module-specific CSS: placed in the feature subfolder, e.g.
customer/list/customer-list.css
Flow of Operations
List Page Flow
{module}-list-main.js ← Entry point: sets up event listeners, triggers initial load
└── {module}-list-loading-main.js ← Fetches data via fetchData(), manages request deduplication,
│ shows/hides skeleton loader
├── {module}-list-skeleton-loader.js ← Renders skeleton rows while loading
└── {module}-list-dom-population.js ← Renders actual data into the DOM
└── {module}-list-table-rows.html.js ← Returns HTML string for each row
Detailed flow:
main.jscalls the loading function on page load and wires up buttons/filters- Loading function calls
getActiveFilterParams(), shows skeleton loader, callsfetchData(route) - On response: removes skeleton, calls
addXxxToDom()from dom-population file - Dom-population builds HTML (often via a
.html.jstemplate file) and inserts it, then attaches event listeners
Create Flow (Modal)
{module}-list-main.js
└── {module}-create-main.js ← displayXxxCreateModalForm() opens modal with HTML from modal-form.html.js
└── submitModalForm() / submitCreate() ← from general/ajax/
└── on success: reload list
Update Flow (Modal or Inline)
{module}-list-main.js or {module}-read-main.js
└── {module}-update-main.js or modal-form handler
└── submitUpdate() / submitModalForm() ← from general/ajax/
└── on success: update row in DOM or reload
Read Page Flow
{module}-read-main.js
└── {module}-read-loading.js ← fetches detail data
└── {module}-read-populate-dom.js ← renders detail view
Ajax Layer (general/ajax/)
All HTTP calls go through these shared helpers - no ad-hoc fetch() calls:
| File | Purpose |
|---|---|
fetch-data.js |
GET requests |
submit-create.js |
POST to create a resource |
submit-update-data.js |
PUT to update a resource |
submit-delete-request.js |
DELETE a resource |
modal-submit-request.js |
submitModalForm() — handles modal form submit lifecycle |
submit-file-upload-request.js |
POST with FormData for file uploads |
ajax-util/fail-handler.js |
Shared error/validation error display |
ajax-util/ajax-loading-animation.js |
Button loading state |
Ajax
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
Request with fetch()
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();
});
Ajax helper functions
The slim-example-project has helper functions to send CRUD requests to the server with the correct headers and method. They 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.
Fail handler
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 from the current page 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. The user is informed with a flash message.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. The user is asked to try again and report the error.
For the other error status codes, a flash message is shown with the status code and the status text.
Fetch data - GET request
This fetchData() helper function can be used to fetch data from the server.
It sends a GET request to the server and returns a promise that resolves to the response body as JSON.
Usage example
The only parameter is the route after the base path (e.g. users/1 or users?param=1).
fetchData('users?param=1')
.then(jsonResponse => {
// Code
})
.catch(error => {
console.error(error);
});
Ajax function
Click to expand
File: public/assets/general/ajax/fetch-data.js
import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.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
}
Submit update - PUT request
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 the 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 designed 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.
Usage example
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);
});
});
Ajax function
Click to expand
File: public/assets/general/ajax/submit-update-data.js
import {getFormData, toggleEnableDisableForm} from "../page-component/modal/modal-form.js";
import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.js";
import {closeModal} from "../page-component/modal/modal.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();
});
}
Submit modal form - POST or PUT request
In the slim-example-project, all forms except the login form are in modal boxes, but the Ajax function can easily be adapted to support other use-cases.
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 the modal box on success
- Show errors if request failed and enable form fields
The submitModalForm() function executes all these steps and returns a promise that resolves to
the response body as JSON.
These are the parameters:
- HTML id of the form (to check the validity of the fields and retrieve the form data)
- Route after the base path (e.g.
users) - HTTP method (
POSTorPUT)
Usage example
submitModalForm('create-user-modal-form', 'users', 'POST')
.then((responseJson) => {
// Inform user about success
displayFlashMessage('success', 'User created successfully');
// Reload user list
loadUserList();
}).catch(error => {
console.error(error);
})
Ajax function
Click to expand
File: public/assets/general/ajax/submit-modal-form.js
import {getFormData, toggleEnableDisableForm} from "../page-component/modal/modal-form.js";
import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.js";
import {closeModal} from "../page-component/modal/modal.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();
});
}
Submit delete - DELETE request
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.
Usage example
document.querySelector('#delete-client-btn')?.addEventListener('click', () => {
if (confirm('Are you sure that you want to delete this client?')) {
submitDelete(`clients/1`).then(() => {
// Redirect to client list page if request was successful
location.href = `clients/list`;
});
}
;
});
Ajax function
Click to expand
File: public/assets/general/ajax/submit-delete-request.js
import {basePath} from "../general-js/config.js";
import {handleFail} from "./ajax-util/fail-handler.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();
});
}