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


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:

  1. main.js calls the loading function on page load and wires up buttons/filters
  2. Loading function calls getActiveFilterParams(), shows skeleton loader, calls fetchData(route)
  3. On response: removes skeleton, calls addXxxToDom() from dom-population file
  4. Dom-population builds HTML (often via a .html.js template 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:

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 =&gt; {
            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.

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 =&gt; {
            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:

The submitModalForm() function executes all these steps and returns a promise that resolves to the response body as JSON.

These are the parameters:

  1. HTML id of the form (to check the validity of the fields and retrieve the form data)
  2. Route after the base path (e.g. users)
  3. HTTP method (POST or PUT)

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 =&gt; {
            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 =&gt; {
            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();
        });
}
^