TQWeb-ADM Page Development Guide¶
This guide explains how to add new pages to the tqweb-adm application following the established ES6 module pattern.
Design System Reference: Before creating any new page, read the design system files:
doc/design-system/tq-tokens.css— Design tokens (colors, shadows, spacing)doc/design-system/tq-components.md— Canonical HTML snippets for all UI componentsdoc/design-system/tq-templates.md— Page skeleton templates (T1-T5)Every new page must use the canonical components and start from the appropriate template.
Overview¶
The tqweb-adm application uses ES6 modules for all page-specific JavaScript code. Each HTML page has a dedicated JavaScript module that handles all functionality for that page.
Key Principles¶
- One module per page: Each HTML page has exactly one corresponding JavaScript module
- Module naming: The module filename matches the HTML filename (e.g.,
tripmaker-dash.html→tripmaker-dash.js) - No subdirectories: All page modules are placed directly in
js/modules/(not in subdirectories) - ES6 imports: Use ES6 import/export syntax, not global scope
- Initialization in HTML: Module initialization happens in
$(document).ready()within the HTML file - Authentication: Pages requiring login must call
setGlobalHandlers({ requireAuth: true })to enable OIDC token-based authentication
File Structure¶
tqweb-adm/
├── my-page.html # HTML page
└── js/
└── modules/
├── globals.js # Core utilities (tlinq, getUserSession, etc.)
└── my-page.js # Page-specific ES6 module
Page Header and Footer Templates¶
The application uses lazy-loading for page headers and footers to avoid code duplication. Templates are loaded dynamically based on the CSS framework used.
Available Templates¶
- header_bootstrap.html - Bootstrap-based header (for Bootstrap pages)
- header.html - Foundation-based header (for Foundation pages)
- header_uikit.html - UIKit-based header (for UIKit pages)
Template Loading¶
For Bootstrap Pages:
<body style="visibility:hidden">
<!-- Header loaded from template -->
<header id="pgheader" data-load-template="header_bootstrap.html"></header>
<!-- Page content here -->
<!-- Load page utilities -->
<script src="js/pageutil.js"></script>
<!-- In your initialization -->
<script type="module">
import * as $page from './js/modules/my-page.js';
$(document).ready(function() {
// Load templates (header/footer)
loadBootstrapTemplates();
// Initialize your page
$page.initializePage();
});
</script>
</body>
Note: The
style="visibility:hidden"on<body>prevents content flash before authentication. For pages withrequireAuth: true,setGlobalHandlers()reveals the body after auth passes. For pages without auth requirements, yourinitializePage()should setdocument.body.style.visibility = 'visible'after initialization.
For Foundation Pages:
<body>
<!-- Header loaded from template -->
<header id="pgheader" data-load-template="header.html"></header>
<!-- Page content here -->
<!-- Load page utilities -->
<script src="js/pageutil.js"></script>
<!-- In your initialization -->
<script>
$(document).ready(function() {
// Load templates (header/footer)
loadPageTemplates(); // Note: Foundation version
// Initialize your page
// ...
});
</script>
</body>
Template Functions¶
Two template loading functions are available:
loadBootstrapTemplates()- For Bootstrap-based pages- Loads HTML templates from files
- Handles visibility based on login status (
data-visible-whenattributes) -
NO Foundation initialization (Bootstrap doesn't need it)
-
loadPageTemplates()- For Foundation-based pages - Loads HTML templates from files
- Calls
.foundation()on loaded content - Handles visibility based on login status
Template Visibility Control¶
Templates support conditional visibility based on user login status:
data-visible-when="guest"- Shown only when NOT logged indata-visible-when="member"- Shown only when logged indata-visible-when="employee"- Shown only for employee users
Example from header_bootstrap.html:
<li class="nav-item" data-visible-when="member">
<a class="nav-link" href="visamgmt.html">
<i class="bi bi-passport"></i> Visa Management
</a>
</li>
<li class="nav-item" data-visible-when="guest">
<a class="nav-link" href="login.html">
<i class="bi bi-box-arrow-in-right"></i> Login
</a>
</li>
Step-by-Step Guide¶
Step 1: Create the JavaScript Module¶
Create your module file in js/modules/ with the same name as your HTML page:
File: js/modules/my-page.js
/**
* My Page Module
* ES6 module for the My Page functionality
*/
import { tlinq, getUserSession } from './globals.js';
// ==================== API Client ====================
const API = {
/**
* Make an API request using tlinq()
*/
async request(endpoint, data = {}) {
const session = getUserSession();
const requestData = { ...data, session };
return tlinq(`mymodule${endpoint}`, requestData);
},
async getData(id) {
return this.request('/data/get', { id });
},
async saveData(data) {
return this.request('/data/save', data);
}
};
// ==================== Utility Functions ====================
const Utils = {
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
},
showAlert(type, message, duration = 5000) {
const alertClass = {
'success': 'alert-success',
'error': 'alert-danger',
'warning': 'alert-warning',
'info': 'alert-info'
}[type] || 'alert-info';
const iconClass = {
'success': 'bi-check-circle-fill',
'error': 'bi-exclamation-circle-fill',
'warning': 'bi-exclamation-triangle-fill',
'info': 'bi-info-circle-fill'
}[type] || 'bi-info-circle-fill';
const alert = $(`
<div class="alert ${alertClass} alert-dismissible fade show" role="alert">
<i class="bi ${iconClass} me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
`);
$('#alertContainer').append(alert);
if (duration > 0) {
setTimeout(() => alert.alert('close'), duration);
}
},
sanitizeHtml(html) {
const temp = document.createElement('div');
temp.textContent = html;
return temp.innerHTML;
}
};
// ==================== Page State ====================
let currentData = null;
let isLoading = false;
// ==================== Exported Functions ====================
/**
* Initialize the page
* This is called from $(document).ready() in the HTML
*/
export function initializePage() {
loadData();
setupEventListeners();
}
function setupEventListeners() {
$('#saveButton').on('click', saveData);
$('#refreshButton').on('click', loadData);
}
async function loadData() {
if (isLoading) return;
isLoading = true;
$('#loadingIndicator').removeClass('d-none');
try {
const data = await API.getData(123);
currentData = data;
renderData(data);
Utils.showAlert('success', 'Data loaded successfully');
} catch (error) {
Utils.showAlert('error', 'Failed to load data: ' + error.message);
} finally {
isLoading = false;
$('#loadingIndicator').addClass('d-none');
}
}
function renderData(data) {
$('#dataContainer').html(`
<div class="card">
<div class="card-body">
<h5>${Utils.sanitizeHtml(data.title)}</h5>
<p>${Utils.sanitizeHtml(data.description)}</p>
</div>
</div>
`);
}
export async function saveData() {
const formData = {
title: $('#titleInput').val(),
description: $('#descriptionInput').val()
};
try {
await API.saveData(formData);
Utils.showAlert('success', 'Data saved successfully');
loadData();
} catch (error) {
Utils.showAlert('error', 'Failed to save data: ' + error.message);
}
}
export function deleteData(id) {
if (window.confirm('Are you sure you want to delete this item?')) {
// Delete logic here
Utils.showAlert('success', 'Item deleted');
}
}
Step 2: Create the HTML Page¶
Create your HTML file in the tqweb-adm/ directory:
File: my-page.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Page</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body style="visibility:hidden">
<header id="pgheader" data-load-template="header_bootstrap.html"></header>
<!-- Your page content here -->
<div class="container" style="margin-top: 80px;">
<h1>My Page</h1>
<div id="dataContainer"></div>
<div id="loadingIndicator" class="d-none">
<div class="spinner-border"></div>
<p>Loading...</p>
</div>
<button id="saveButton" class="btn btn-primary" onclick="window.MyPage.saveData()">
Save
</button>
<button id="refreshButton" class="btn btn-secondary">
Refresh
</button>
</div>
<!-- Alert Container -->
<div id="alertContainer" class="position-fixed top-0 end-0 p-3" style="z-index: 1100;">
<!-- Alerts will be shown here -->
</div>
<!-- Load jQuery and Bootstrap -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="js/pageutil.js"></script>
<script src="js/tqpro-compat.js"></script>
<script>loadBootstrapTemplates();</script>
<!-- Load Page Module -->
<script type="module">
import * as $page from './js/modules/my-page.js';
$(document).ready(function() {
// Initialize page
$page.initializePage();
// Expose functions to window for onclick handlers
window.MyPage = $page;
});
</script>
</body>
</html>
Important: The
style="visibility:hidden"on<body>prevents content flash before authentication. ThesetGlobalHandlers({ requireAuth: true })call in your module'sinitializePage()handles the OIDC auth check and reveals the body. See the Authentication section below.
Module Structure Explained¶
1. Imports¶
Always import from globals.js at the top:
Available from globals.js:
- tlinq(endpoint, data) - Standard async API request function (returns Promise resolving to apiData)
- getUserSession() - Get current user session token
- setGlobalHandlers(options) - Initialize page handlers. Pass { requireAuth: true } for authenticated pages
- createId() - Generate unique IDs
- Other utility functions
2. API Client Section¶
Organize all API calls in a dedicated object:
const API = {
async request(endpoint, data = {}) {
const session = getUserSession();
const requestData = { ...data, session };
return tlinq(`mymodule${endpoint}`, requestData);
},
async getItems() {
return this.request('/items/list');
}
};
Important: Always add session to API requests:
3. Utility Functions Section¶
Keep page-specific utilities in the Utils object:
const Utils = {
formatDate(dateString) { /* ... */ },
showAlert(type, message) { /* ... */ },
sanitizeHtml(html) { /* ... */ }
};
4. State Management¶
Use module-level variables for state (they're private to the module):
5. Exported Functions¶
Export functions that need to be called from HTML or other modules:
export function initializePage() {
// Called from $(document).ready()
}
export function saveData() {
// Called from onclick handlers
}
export function deleteItem(id) {
// Called with parameters from onclick
}
Internal functions (not exported) are private to the module:
HTML Integration Patterns¶
Pattern 1: Event Handlers in JavaScript (Preferred)¶
Set up event handlers in the module:
function setupEventListeners() {
$('#saveButton').on('click', saveData);
$('#cancelButton').on('click', cancel);
}
HTML:
Pattern 2: Inline onclick Handlers¶
For dynamic content or when you need parameters:
HTML (generated dynamically):
function renderItem(item) {
return `
<button onclick="window.MyPage.deleteItem(${item.id})">
Delete
</button>
`;
}
Expose in HTML's module script:
Best Practices¶
1. Always Use Async/Await for API Calls¶
✅ Good:
async function loadData() {
try {
const data = await API.getData();
renderData(data);
} catch (error) {
Utils.showAlert('error', 'Failed to load data');
}
}
❌ Avoid:
function loadData() {
API.getData().then(data => {
renderData(data);
}).catch(error => {
Utils.showAlert('error', 'Failed to load data');
});
}
2. Always Sanitize User Input in HTML¶
✅ Good:
❌ Avoid:
3. Always Add Session to API Requests¶
✅ Good:
async request(endpoint, data = {}) {
const session = getUserSession();
const requestData = { ...data, session };
return tlinq(`mymodule${endpoint}`, requestData);
}
❌ Avoid:
async request(endpoint, data = {}) {
return tlinq(`mymodule${endpoint}`, data); // Missing session!
}
4. Use Proper Error Handling¶
✅ Good:
try {
const data = await API.getData();
renderData(data);
Utils.showAlert('success', 'Data loaded');
} catch (error) {
console.error('Load error:', error);
Utils.showAlert('error', 'Failed to load: ' + error.message);
}
5. Show Loading States¶
✅ Good:
async function loadData() {
$('#loadingIndicator').removeClass('d-none');
try {
const data = await API.getData();
renderData(data);
} finally {
$('#loadingIndicator').addClass('d-none');
}
}
Common Patterns¶
Modal Forms¶
export function showEditModal(itemId) {
const item = findItemById(itemId);
// Populate form
$('#itemName').val(item.name);
$('#itemDescription').val(item.description);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editModal'));
modal.show();
}
export async function saveModal() {
const formData = {
name: $('#itemName').val(),
description: $('#itemDescription').val()
};
try {
await API.saveItem(formData);
Utils.showAlert('success', 'Item saved');
// Hide modal
const modal = bootstrap.Modal.getInstance(document.getElementById('editModal'));
modal.hide();
// Reload data
loadItems();
} catch (error) {
Utils.showAlert('error', 'Failed to save: ' + error.message);
}
}
Confirmation Dialogs¶
export function deleteItem(itemId) {
if (window.confirm('Are you sure you want to delete this item?')) {
performDelete(itemId);
}
}
async function performDelete(itemId) {
try {
await API.deleteItem(itemId);
Utils.showAlert('success', 'Item deleted');
loadItems();
} catch (error) {
Utils.showAlert('error', 'Failed to delete: ' + error.message);
}
}
Dynamic Content Generation¶
function renderItems(items) {
const container = $('#itemsContainer');
container.empty();
items.forEach(item => {
const card = createItemCard(item);
container.append(card);
});
}
function createItemCard(item) {
return `
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">${Utils.sanitizeHtml(item.name)}</h5>
<p class="card-text">${Utils.sanitizeHtml(item.description)}</p>
<button class="btn btn-primary"
onclick="window.MyPage.editItem(${item.id})">
Edit
</button>
<button class="btn btn-danger"
onclick="window.MyPage.deleteItem(${item.id})">
Delete
</button>
</div>
</div>
`;
}
Testing the Module Pattern¶
To verify your module is working correctly:
- Check browser console for import errors
- Verify getUserSession() works - no "is not a function" errors
- Test all onclick handlers - make sure functions are exposed to window
- Check API calls include session - inspect network requests
Migration from Old Pattern¶
If you have old code using global scripts:
Old (Don't use):
<script src="js/my-script.js"></script>
<script>
// Global scope code
function myFunction() {
window.getUserSession(); // Might not be defined yet!
}
</script>
New (Use this):
<script type="module">
import * as $page from './js/modules/my-page.js';
$(document).ready(function() {
$page.initializePage();
window.MyPage = $page;
});
</script>
Authentication (OIDC)¶
Since TQ-51, the admin site (tqweb-adm) uses native OIDC authentication via Keycloak with the oidc-client-ts library. This replaces the previous oauth2-proxy approach.
Enabling Authentication on a Page¶
Pages that require authenticated access must call setGlobalHandlers() with the requireAuth option:
This triggers the OIDC Authorization Code flow with PKCE. If the user is not authenticated, they are redirected to Keycloak for login and then returned to the page via callback.html.
How It Works¶
globals.jsimportsauth-service.js, which manages the OIDC lifecycleauth-service.jsusesoidc-client-tsto perform Authorization Code flow with PKCE against Keycloak- OIDC configuration is loaded from the backend
auth/configendpoint at runtime (no hardcoded URLs) - On successful login, the JWT access token is stored both in oidc-client-ts internal state and cached in
sessionStorageasss_access_token - The
tlinq()function automatically attaches the JWT as aBearertoken in theAuthorizationheader for all API calls (usingss_access_tokenfallback on pages where oidc-client-ts is not loaded) - Token refresh is handled silently via
silent-renew.html - Pages with
requireAuth: truehide their<body>withvisibility:hiddento prevent content flash before authentication;setGlobalHandlers()reveals the body after auth passes
Key Files¶
| File | Purpose |
|---|---|
js/modules/auth-service.js |
OIDC client wrapper — login, logout, token management, silent renewal error handling |
js/modules/oidc-config.js |
Loads OIDC config from backend auth/config endpoint, builds UserManager settings |
callback.html |
OIDC redirect callback handler |
silent-renew.html |
Silent token renewal iframe |
loggedout.html |
Post-logout page — clears sessionStorage, cookies |
Pages Without Authentication¶
For public-facing pages or pages that should work without login, omit the requireAuth option:
Session Storage Keys¶
| Key | Purpose |
|---|---|
ss_user_uid |
Keycloak user UUID (profile.sub) — used for user identification |
ss_access_token |
Cached OIDC JWT access token — used by tlinq() for Bearer auth |
ss_id_token |
OIDC ID token — used for logout |
ss_loggedin |
Login state flag ('true' when logged in) |
ss_username |
User's preferred username or email |
ss_greeting |
Greeting string (e.g., "Hello, John") |
Session Token vs JWT¶
- Authenticated pages (
requireAuth: true): The JWT Bearer token is sent automatically bytlinq(). Thesessionfield in API requests can be empty. - Guest/public pages: The
sessionfield fromgetUserSession()is used as before. Guest permissions are defined inapi-roles.properties.
Binary Download Endpoints (Excel, PDF)¶
The tlinq() function always parses the response as JSON and extracts apiData. Endpoints that return binary files (application/octet-stream) — such as Excel exports — cannot use tlinq() because the response is a blob, not JSON.
For these endpoints, use raw fetch() with getAuthHeaders() from globals.js:
import { getUserSession, getAuthHeaders } from './globals.js';
async function downloadFile() {
const session = getUserSession();
const headers = await getAuthHeaders();
const response = await fetch('/tlinq-api/mymodule/export', {
method: 'POST',
headers,
body: JSON.stringify({ session })
});
if (!response.ok) { /* handle error */ return; }
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
getAuthHeaders() resolves the OIDC access token using the same logic as tlinq() and returns a headers object with Content-Type and Authorization set correctly. Do not build the Authorization header manually from getUserSession() — in OIDC mode that returns an empty string and the request will be treated as unauthenticated.
CSS Framework Notes¶
The tqweb-adm application has pages using different CSS frameworks:
- Foundation Framework: Some older pages (e.g., visamgmt.html) use Foundation and require
$(document).foundation();in initialization - Bootstrap: Newer pages (e.g., TripMaker) use Bootstrap and do not need Foundation initialization
Only include $(document).foundation(); if your page actually uses the Foundation framework. Check which CSS framework your page loads before including this line.
Reference Examples¶
The following pages in tqweb-adm follow this pattern:
- visamgmt.html / visamgmt.js - Complex multi-step form with file uploads (uses Foundation)
- tripmaker-dash.html / tripmaker-dash.js - Dashboard with filtering, KPI cards, and CRUD modals (uses Bootstrap, shared module pattern)
- tripmaker-common.js - Shared API client, utilities, and destination autocomplete for TripMaker pages (uses Bootstrap)
Study these files for real-world examples of the patterns described in this guide.
Troubleshooting¶
"getUserSession is not a function"¶
Cause: Module is using window.getUserSession() instead of importing it.
Fix:
// At top of module
import { getUserSession } from './globals.js';
// In API client
const session = getUserSession(); // Not window.getUserSession()
"Module not found" error¶
Cause: Incorrect import path or file not in js/modules/
Fix:
// Correct - relative to HTML file
import * as $page from './js/modules/my-page.js';
// Wrong
import * as $page from 'js/modules/my-page.js';
onclick handlers not working¶
Cause: Functions not exposed to window scope
Fix:
Race condition with globals¶
Cause: Module tries to use globals before they're loaded
Fix: Always wait for $(document).ready() in the HTML script block, and imports will ensure proper loading order.
Summary Checklist¶
When creating a new page:
- [ ] Module file in
js/modules/with same name as HTML - [ ] Import
tlinqandgetUserSessionfrom globals.js - [ ] API client adds session to all requests
- [ ] Export
initializePage()function - [ ] Export functions used in onclick handlers
- [ ] HTML includes
pageutil.jsandtqpro-compat.jsscripts - [ ] HTML uses
<body style="visibility:hidden">for content flash prevention - [ ] HTML calls
loadBootstrapTemplates()for header/footer loading - [ ] Initialize in
$(document).ready() - [ ] Call
$site.setGlobalHandlers({ requireAuth: true })for authenticated pages - [ ] Expose module and functions to window
- [ ] Use
Utils.sanitizeHtml()for user input - [ ] Include proper error handling
- [ ] Show loading states during async operations
CDN Media Browser Module¶
The cdn-browser.js module provides a reusable modal for browsing S3/CloudFront images and uploading new ones. It can be added to any page that has URL input fields for CDN images.
Quick Integration¶
-
Import the module (it auto-binds on
DOMContentLoaded): -
Add
data-cdn-*attributes to your browse buttons:<div class="input-group"> <input type="url" id="my_image_url" class="form-control form-control-sm" ...> <button type="button" class="btn btn-outline-secondary btn-sm" data-cdn-purpose="hotel-photos" data-cdn-allow-upload="true" data-cdn-target="#my_image_url"> <i class="bi bi-folder2-open"></i> </button> </div>
Attributes:
| Attribute | Required | Description |
|-----------|----------|-------------|
| data-cdn-purpose | Yes | Purpose ID from MediaConfig (e.g. hotel-photos, offer-images) |
| data-cdn-allow-upload | No | true to show the Upload tab (default: true) |
| data-cdn-target | Yes | CSS selector for the input field to receive the CDN URL |
Programmatic API¶
For cases where auto-bind isn't sufficient:
import { openCdnBrowser } from './cdn-browser.js';
openCdnBrowser({
purpose: 'hotel-photos',
allowUpload: true,
onSelect(cdnUrl) {
document.getElementById('my_field').value = cdnUrl;
}
});
Available Purposes¶
Purposes are defined in config/nts-client.xml under <MediaConfig><Purposes>. Current purposes:
- hotel-photos — Hotel package images
- offer-images — Offer/deal images
- destination-heroes — Destination hero banners
- marketing-campaigns — Marketing media (upload-only, no browser)
Backend API¶
The module calls three Media API endpoints:
- POST /media/cdn/browse — List folders and images
- POST /media/cdn/upload — Upload with resize/crop/WebP conversion
- POST /media/cdn/templates — Get available image templates
See Media API Specification for details.
Version: 1.3 Last Updated: 2026-03-20 Maintainer: Development Team
Revision History:
- v1.3 (2026-03-20): Added CDN Media Browser module documentation (TQ-80)
- v1.2 (2026-02-21): Updated for TQ-53 — content flash prevention (visibility:hidden), session storage keys table, oidc-config.js, updated HTML template examples
- v1.1 (2026-02-19): Added OIDC authentication section, requireAuth pattern, updated checklist (TQ-51)
- v1.0 (2025-11-20): Initial version