Now.js Framework Documentation

Now.js Framework Documentation

FormManager

EN 04 Sep 2026 01:40

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:

  1. data-redirect-after-login on the form, when it is set — the intended route is skipped
  2. the intended route the guard stored when it bounced the visitor to the login page
  3. RouterManager.config.auth.redirects.afterLogin
  4. / — the built-in after_login target

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

  1. FormManager reads the fields listed in data-watch-fields.
  2. When one of the fields in data-watch-trigger changes, FormManager calls data-watch-api. If data-watch-on-load is not set to false, the watched API is also called once after the initial load payload has been applied.
  3. The response payload is merged back into the form state with setFormData().
  4. Existing bindings such as data-text, data-attr, data-if, and data-for update 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

  1. The form submits via AJAX.
  2. FormManager normalizes the success payload into a canonical schema similar to TableManager:
{
  data: [...],
  meta: {
    page: 1,
    pageSize: 20,
    total: 23,
    totalPages: 2
  },
  filters: {},
  options: {}
}
  1. The full normalized payload is exposed on context.state.
  2. The primary data source is exposed on context.data.
  3. Pagination buttons are rendered into data-submit-pagination-target and update the field named by data-submit-page-field before 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, so instance.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>