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 |
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 |
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 |
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);
}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} |
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
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