Now.js Framework Documentation
FormManager
FormManager
Overview
FormManager is the form management system in Now.js Framework. It supports validation, auto-submit, and API integration.
When to use:
- Need form handling
- Need validation
- Need AJAX submission
- Need auto-enhance form elements
Why use it:
- ✅ Automatic validation
- ✅ AJAX submission
- ✅ Loading states
- ✅ Error display
- ✅ Auto-enhance elements
- ✅ Multiple submit handlers
- ✅ Declarative result binding for search/list forms
Basic Usage
HTML Declarative
<form data-form="user-create"
data-action="/api/users"
data-method="POST">
<input type="text" name="name" required>
<input type="email" name="email" required>
<button type="submit">Submit</button>
</form>With Success Redirect
<form data-form="user-create"
data-action="/api/users"
data-success-redirect="/users">
...
</form>With Notification
<form data-form="contact"
data-action="/api/contact"
data-success-message="Message sent successfully!">
...
</form>Data Attributes
| Attribute | Description |
|---|---|
data-form |
Required. Turns the form on and names it. Some names are reserved — see Reserved form names |
data-action |
API endpoint |
data-method |
HTTP method (POST, PUT, PATCH) |
data-success-redirect |
Redirect URL on success |
data-success-message |
Success notification |
data-error-message |
Error notification |
data-validate |
Enable validation |
data-confirm |
Confirmation message |
data-load-cache |
Enable caching for data-load-api GET requests |
data-load-cache-time |
Cache TTL in milliseconds for data-load-api |
data-load-options-cache |
Enable caching for data-load-options-api GET requests |
data-load-options-cache-time |
Cache TTL in milliseconds for data-load-options-api |
data-watch-api |
API endpoint that should be called again when watched fields change |
data-watch-method |
HTTP method for data-watch-api (GET by default) |
data-watch-fields |
Comma-separated field names/ids to send to the watched API |
data-watch-trigger |
Comma-separated field names/ids that trigger the watched API when they change |
data-watch-debounce |
Debounce in milliseconds before the watched API is called |
data-watch-on-load |
Call the watched API once after initial form data is ready (true by default, set to false to avoid the extra initial call) |
data-submit-target |
CSS selector for the container that should be rebound from a successful AJAX response |
data-submit-pagination-target |
CSS selector for the container where pagination buttons should be rendered |
data-submit-query-params |
Update the current URL query string from form values after a successful AJAX submit |
data-submit-query-fields |
Comma-separated field names that should be written into the URL query string |
data-submit-page-field |
Field name used for page changes when pagination re-submits the same form |
Form level — validation and messages
| Attribute | Description |
|---|---|
data-validation |
Turn whole-form validation on or off (true/false) |
data-validate-on-submit |
Validate when the form is submitted (true/false) |
data-error-class |
CSS class applied to a failing field |
data-error-message-class |
CSS class of the error message |
data-error-container |
CSS selector of the container for form-level errors |
data-success-container |
CSS selector of the container for the success message |
data-show-errors-inline |
Show errors under each field (true/false) |
data-show-errors-in-notification |
Show errors as a notification (true/false) |
data-show-success-inline |
Show the success message inside the form (true/false) |
data-show-success-in-notification |
Show the success message as a notification (true/false) |
data-auto-clear-errors |
Clear errors automatically (true/false) |
data-auto-clear-errors-delay |
Delay before clearing errors, in milliseconds |
data-auto-focus-error |
Focus the first failing field (true/false) |
data-auto-scroll-to-error |
Scroll to the first failing field (true/false) |
data-loading-text |
Submit button text while the request is in flight (omitted = the button keeps its label and shows only the spinner) |
Form level — security and submission
| Attribute | Description |
|---|---|
data-csrf |
Attach a CSRF token or not (true/false) |
data-csrf-token |
Supply the CSRF token directly instead of letting SecurityManager find it |
data-csrf-header |
Header name used to send the CSRF token |
data-sanitize-input |
Sanitize values before sending (true/false); set false when the server encodes them itself |
data-submit-pagination-window |
How many page numbers the pagination bar shows |
data-modal-options |
Options of the modal this form lives in, as JSON |
data-cascade-api |
Endpoint for loading cascading options for the whole form |
data-load-url-params |
Prefill from the query string (true/false) |
data-url-params-required-fields |
Comma-separated fields the query string must supply |
data-redirect |
Destination after a successful submit |
Form level — the submit lock (double-submit protection)
While a form is being submitted FormManager locks it where the visitor can see it: every submit button of the form (including one outside it that points at it with the form attribute) is disabled, gets the loading class (a spinner on .btn) and aria-busy="true", and the <form> gets the submitting class. Submits that arrive meanwhile are ignored.
| Situation | Result |
|---|---|
| In flight, waiting for the response | Locked |
| The server answers with an error / validation fails | Unlocked at once — correct the form and send it again |
Success, and the page is changing (a redirect action, a full reload, a login form, data-redirect) |
Stays locked until the new page takes over, doubleSubmitTimeout (2000 ms) at most |
| Success, staying on the page (notification, table reload, a link opened in another window) | Unlocked at once |
Submitted again within doubleClickTime (500 ms) of the previous submit |
Treated as a double click, not sent |
A button that was already disabled is not enabled when the lock is released.
| Attribute | Description |
|---|---|
data-prevent-double-submit |
false = no protection and no button lock: every submit is sent even when they overlap, e.g. a barcode box that submits one item at a time (a disabled submit button makes the browser swallow Enter) (default true) |
data-double-submit-timeout |
Longest time (ms) the lock is kept after a success while the page changes (default 2000) |
data-double-click-time |
Window (ms) in which the next submit counts as a double click (default 500) |
data-show-loading-on-submit |
false = no loading class on the form and buttons (the buttons are still disabled) |
Field level — per-field error messages
Put these on an <input>, <select> or <textarea> to replace the standard text.
| Attribute | Description |
|---|---|
data-error-required |
Message when the field is empty |
data-error-minlength |
Message when the value is too short; supports {minlength} |
data-error-maxlength |
Message when the value is too long; supports {maxlength} |
data-error-min |
Message when the value is below the minimum; supports {min} |
data-error-max |
Message when the value is above the maximum; supports {max} |
data-error-pattern |
Message when the format does not match |
data-error-validate |
Message when a custom validator fails |
data-error-validation |
Fallback message when no specific one is given |
data-validate-fn |
Name of a window function that validates this field |
data-cascade-source |
Name of the field this one depends on |
data-autocomplete |
Set true on an autocomplete field so focus handling leaves it alone |
Server-side field errors returned in the API errors object stay visible through subsequent validation. They clear when the user edits the field (input or change), not just because the field was already dirty. Toasts prefix a known field as Field label: message; unknown fields show the message without a label.
Field level — remembering what was typed
| Attribute | Description |
|---|---|
data-persist |
Remember the value and restore it next time |
data-persist-key |
Storage key; derived from data-form and the current path when omitted |
data-persist-on |
When to store (submit by default) |
data-persist-ttl-days |
Lifetime of the stored value, in days |
data-persist-ttl |
Lifetime used when data-persist-ttl-days is not set |
data-persist-allow-password |
Allow storing password fields, which are skipped by default |
Note: named form fields are submitted exactly like a native form submit. If your sort, filter, keyword, category, or hidden paging inputs live in the same form, they will be re-sent automatically on every AJAX submit and pagination click.
Reserved form names
data-form both switches the form on and names it. Two names are reserved: FormManager
recognises them and applies authentication behaviour no attribute asked for.
data-form="login"
A form named login is treated as the login form of the page:
| What FormManager does | When |
|---|---|
Fills the remembered username back in — reads remember_username from localStorage into input[name="username"] (or the first input[type="email"], then the first input[type="text"]) and ticks input[name="remember"] / input#remember. Passwords are never stored |
While the form initializes |
Signs the session in through AuthManager.setAuthenticatedUser() |
On success, when the response carries both data.user and data.token |
Stores or clears remember_username according to the remember checkbox |
On success |
Redirects on its own through RedirectManager.afterLogin() |
On success |
The redirect is what catches people out: a login form ignores data-success-redirect,
data-redirect and data-use-intended-url. Those are read by determineRedirectUrl(),
which a login form never reaches. RedirectManager.afterLogin() picks the destination:
data-redirect-after-loginon the form, when it is set — the intended route is skipped- the intended route the guard stored when it bounced the visitor to the login page
RouterManager.config.auth.redirects.afterLogin/— the built-inafter_logintarget
A redirect action in the response does not replace this either. Every other form skips
its own redirect when the response carries one; a login form runs
RedirectManager.afterLogin() regardless, and since the action waits out its delay
(1000 ms by default) while afterLogin() navigates immediately, the server's action
loses. Send the destination as data-redirect-after-login, or configure
RouterManager.config.auth.redirects.afterLogin.
<!-- Real login form: no redirect attribute needed, and none would be honoured -->
<form data-form="login" data-action="/api/auth/login" data-method="POST"
data-ajax-submit="true" data-auto-fill-intended-url="true">
<input type="email" name="username" required>
<input type="password" name="password" required>
<label><input type="checkbox" name="remember"> Remember me</label>
<button type="submit">Sign in</button>
</form>| Attribute | Description |
|---|---|
data-redirect-after-login |
Where to go after signing in, instead of the intended route or the configured home |
data-auto-fill-intended-url |
Add a hidden intended_url field from ?redirect= / ?return_to=, or from the route stored in sessionStorage.auth_intended_route, and use it as data-redirect-after-login |
data-form="register"
When the response of a form named register carries both data.user and data.token,
FormManager signs the new account in the same way — one call to
AuthManager.setAuthenticatedUser(), no separate login step. Its redirect is the ordinary
one, so data-success-redirect and data-redirect work as usual.
Any other form that posts credentials
A demo, a sign-in widget inside a bigger page, or a modal that authenticates a second
account: name it anything else — signin, login-demo, account-login — and the form
stays an ordinary form, redirecting only where you tell it to.
Declarative Watched API Binding
Use this pattern when a form has derived UI that depends on multiple fields and you want the server to return a payload that can be rebound with normal TemplateManager directives.
How it works
- FormManager reads the fields listed in
data-watch-fields. - When one of the fields in
data-watch-triggerchanges, FormManager callsdata-watch-api. Ifdata-watch-on-loadis not set tofalse, the watched API is also called once after the initial load payload has been applied. - The response payload is merged back into the form state with
setFormData(). - Existing bindings such as
data-text,data-attr,data-if, anddata-forupdate automatically.
Example: Derived Leave Preview
<form data-form="leave-request"
data-load-api="api/eleave/request/get"
data-watch-api="api/eleave/request/policy"
data-watch-fields="id,leave_id,start_date,start_period,end_date,end_period"
data-watch-trigger="leave_id,start_date,start_period,end_date,end_period"
data-watch-debounce="150">
<select name="leave_id" data-options-key="leave_id" data-attr="value:leave_id"></select>
<input type="date" name="start_date" data-attr="value:start_date">
<input type="date" name="end_date" data-attr="value:end_date">
<aside data-text="preview.leave_type_detail"></aside>
<input type="text" data-attr="value:preview.days" readonly>
<div class="comment" data-text="preview.days_note"></div>
<div data-if="preview.balance_summary">
<div data-for="year in preview.balance_summary.years">
<template>
<div>
<strong data-text="year.heading_text"></strong>
<div data-text="year.summary_text"></div>
</div>
</template>
</div>
</div>
</form>Response Shape
The watched API can return any payload that setFormData() can bind. A common pattern is to return a nested preview object plus any supporting option collections.
{
"success": true,
"data": {
"preview": {
"leave_type_detail": "Vacation • 10 days/year",
"days": "1.5",
"days_note": "Calculated automatically from the selected date range",
"balance_message": "",
"balance_summary": {
"years": []
}
}
}
}Use watched API binding when the UI is a pure function of current form values. If the response should execute server actions such as notification, redirect, modal, or form, use requestApi instead.
data-watch-on-load defaults to true. If data-load-api already returns the derived state you need, set data-watch-on-load="false" to avoid a second initial request.
Use requestApi with data-response-bind="template" when the response should update a non-form target or when the page needs explicit control over which request parameters are sent.
When a result form should keep its filters shareable or refresh-safe, add data-submit-query-params="true" and optionally data-submit-query-fields="year,leave_id,...". FormManager will update the browser query string with the submitted values after each successful AJAX submit.
Declarative Result Binding
Use this pattern when a form should submit with AJAX, bind the response into a result container, and let FormManager create pagination buttons automatically.
How it works
- The form submits via AJAX.
- FormManager normalizes the success payload into a canonical schema similar to TableManager:
{
data: [...],
meta: {
page: 1,
pageSize: 20,
total: 23,
totalPages: 2
},
filters: {},
options: {}
}- The full normalized payload is exposed on
context.state. - The primary data source is exposed on
context.data. - Pagination buttons are rendered into
data-submit-pagination-targetand update the field named bydata-submit-page-fieldbefore re-submitting the same form.
Example: Search Form With Cards
<form data-form="partSearch"
action="api/parts/search/get"
method="get"
data-ajax-submit="true"
data-submit-target="#partResults"
data-submit-pagination-target="#partResultsPagination"
data-submit-page-field="page">
<input type="text" name="q" placeholder="Search...">
<select name="category_id">
<option value="">All categories</option>
</select>
<input type="hidden" name="page" value="1">
<input type="hidden" name="limit" value="20">
<button type="submit">Search</button>
</form>
<section id="partResults" class="hidden" data-class="hidden:!submitted" data-on-load="hydratePartResults">
<header>
<p data-if="hasData">
Showing <strong data-text="pagination.from"></strong>
-
<strong data-text="pagination.to"></strong>
of <strong data-text="meta.total"></strong>
</p>
</header>
<div class="grid" data-if="hasData">
<div data-for="item in data">
<template>
<article class="card">
<h3 data-text="item.name"></h3>
<p data-text="item.part_no"></p>
</article>
</template>
</div>
</div>
<p data-if="empty">No results</p>
</section>
<div id="partResultsPagination"></div>Expected API Response
{
"success": true,
"data": {
"data": [{"id": 1, "name": "Gear", "part_no": "GEAR-001"}],
"total": 23,
"page": 1,
"limit": 20,
"pages": 2
}
}Optional data-on-load hydration
function hydratePartResults(element, context) {
const rows = Array.isArray(context.data) ? context.data : [];
const meta = context.state?.meta || {};
console.log('rows', rows);
console.log('page', meta.page, 'of', meta.totalPages);
}Active menu item of a page that loads data
When the data data-load-api returns carries menu_path, the menu selects that link instead of the route's menuPath — for example a document page /order?id=248 that belongs under the /orders?status=INV menu item by its type. Without it, the menu item is chosen as before — see MenuManager: Letting the API choose the menu item.
$order['menu_path'] = '/inventory-orders?status='.$order['document_type'];JavaScript API
// Get a form instance by id — returns the stored state object, not a wrapper with methods
const instance = FormManager.getInstance('my-form');
// Read the form values (accepts an id or an element)
const data = FormManager.getValues('my-form');
// Populate the form — pass silent = true to skip events
FormManager.setFormData(instance, {
name: 'John',
email: 'john@example.com'
}, false);
// Reset the form
FormManager.resetForm(instance);
// Validate the whole form
const isValid = await FormManager.validateForm(instance);
// Submit over ajax
await FormManager.submitAjax(instance, FormManager.getFormData(instance));Every method lives on
FormManager, not on the instance —getInstance()only
returns the state object held in a Map, soinstance.submit()does not exist.
Validation
HTML5 Validation
<input type="text" name="name" required minlength="2" maxlength="50">
<input type="email" name="email" required>
<input type="number" name="age" min="18" max="100">
<input type="url" name="website" pattern="https?://.+">Custom Validation
<input type="text" name="username"
data-validate="username"
data-validate-message="Username must be 3-20 characters">FormManager.registerValidator('username', (value) => {
return /^[a-zA-Z0-9_]{3,20}$/.test(value);
});Async Validation
FormManager.registerValidator('unique-email', async (value) => {
const response = await ApiService.get(`/api/check-email?email=${value}`);
return response.data.available;
});Events
| Event | When Triggered | Detail |
|---|---|---|
form:init |
The form was initialized | {formId, instance} |
form:submitting |
Submission started | {formId, form} |
form:submitted |
Submission succeeded | {formId, response} |
form:error |
Submission failed | {formId, response, errors} (FormError also emits form:error for each field it marks, with {field, messages, element, config}; check formId to tell them apart) |
form:validate |
Whole-form validation ran | {formId, isValid, errors, invalidFields, invalidFieldDetails} |
form:validation:failed |
Validation rejected the form | {formId, errors, invalidFieldDetails} |
form:field:change |
A field value changed | {formId, field, name, value} |
form:reset |
The form was reset | {formId} |
form:data:set |
setFormData() filled the form | {formId, data} |
form:destroy |
The form instance was destroyed | {formId} |
form:upload-progress |
While uploading files | {loaded, total, percent} |
form:urlParamsMissing |
Required URL parameters were missing | {formId, missingParams} |
form:watch:loading |
A data-watch-api request started |
{formId, params} |
form:watch:loaded |
A data-watch-api request returned |
{formId, params, response} |
form:watch:error |
A data-watch-api request failed |
{formId, params, error} |
redirect:start |
About to redirect after a successful submit | {formId, url, delay} |
All of these go out through EventManager.emit(), which does not dispatch a DOM
event — listen with EventManager.on(), not addEventListener():
EventManager.on('form:submitted', (data) => {
console.log('Form submitted:', data.formId, data.response);
});API Reference
FormManager.getInstance(id)
Get form instance
FormManager.submit(element)
Submit form
FormManager.validate(element)
Validate form
Returns: boolean
FormManager.reset()
Reset form
FormManager.getValues(identifier)
Get form values
Returns: Object
FormManager.registerValidator(name, fn, defaultMessage)
Add custom validator
FormManager.reloadFormData(target)
Fetches data-load-api again and fills the form the way the first load did: fields, options, and every table or line-item list bound to the form's data. The cache is bypassed, file inputs are cleared, nothing counts as edited any more, and the fresh values become the baseline a reset returns to.
A table without a data-source of its own gets its rows from the form, so this is how it shows new rows after a save. A response can ask for it with {"type": "redirect", "url": "reload", "target": "form"} (see ResponseHandler).
| Parameter | Type | Description |
|---|---|---|
target |
Object \| HTMLFormElement \| string |
Form instance, form element, or data-form id |
Returns: Promise<boolean> - false when the form is not found, declares no data-load-api, or the request failed
await FormManager.reloadFormData('editproduct');Real-World Examples
Contact Form
<form data-form="contact"
data-action="/api/contact"
data-method="POST"
data-success-message="Message sent successfully!"
data-success-reset="true">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>Message</label>
<textarea name="message" required minlength="10"></textarea>
</div>
<button type="submit">Send</button>
</form>Edit Form
<form data-form="user-edit"
data-action="/api/users/{{id}}"
data-method="PUT"
data-success-redirect="/users"
data-confirm="Confirm save?">
<input type="hidden" name="id" value="{{id}}">
<input type="text" name="name" value="{{name}}">
<input type="email" name="email" value="{{email}}">
<button type="submit">Save</button>
</form>Search Form With Automatic Pagination
<form data-form="usersSearch"
action="/api/users/search"
method="get"
data-ajax-submit="true"
data-submit-target="#userResults"
data-submit-pagination-target="#userResultsPagination"
data-submit-page-field="page">
<input type="text" name="search" placeholder="Keyword">
<select name="status">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<input type="hidden" name="page" value="1">
<input type="hidden" name="limit" value="20">
<button type="submit">Filter</button>
</form>
<div id="userResults">
<div data-for="user in data">
<template>
<article>
<strong data-text="user.name"></strong>
</article>
</template>
</div>
</div>
<div id="userResultsPagination"></div>File Upload
<form data-form="upload"
data-action="/api/upload"
data-enctype="multipart/form-data">
<input type="file" name="document"
accept=".pdf,.doc,.docx"
required>
<button type="submit">Upload</button>
</form>With Custom Handler
form:submitting is emitted through EventManager.emit(), so a listener can
observe a submit but cannot cancel it — there is no DOM event to call
preventDefault() on:
EventManager.on('form:submitting', (data) => {
console.log('Submitting form:', data.formId);
});To take the submit over completely, leave data-form off the element and handle
the native submit event yourself:
const form = document.getElementById('custom-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = FormManager.getValues(form);
// Custom processing
formData.processed = true;
try {
const response = await ApiService.post('/api/custom', formData);
NotificationManager.success('Success!');
} catch (error) {
NotificationManager.error(error.message);
}
});CSS for Validation
/* Invalid field */
.form-group.invalid input,
.form-group.invalid textarea,
.form-group.invalid select {
border-color: #ef4444;
}
/* Error message */
.form-group .error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 4px;
}
/* Valid field */
.form-group.valid input {
border-color: #22c55e;
}
/* Loading state */
form.loading button[type="submit"] {
opacity: 0.7;
pointer-events: none;
}
form.loading button[type="submit"]::after {
content: ' ⏳';
}Common Pitfalls
⚠️ 1. Must Have name Attribute
<!-- ❌ Missing name -->
<input type="text" id="username">
<!-- ✅ Has name -->
<input type="text" name="username">⚠️ 2. Button type
<!-- ❌ Default can submit -->
<button>Click</button>
<!-- ✅ Specify type -->
<button type="submit">Submit</button>
<button type="button">Cancel</button>Related Documentation
- ElementManager - Form elements
- ApiService - API calls
- NotificationManager - Notifications