[email protected] — managed submission to api.hanzo.ai/v1/forms (opt-in pk/token), OnError event, drop dead xhr; backward-compatible

This commit is contained in:
2026-07-24 00:11:10 -07:00
parent d904bee31c
commit 5d3e9de5d0
6 changed files with 15229 additions and 409 deletions
+51
View File
@@ -9,6 +9,57 @@
> Automagic form validation and submission.
## Install
npm install form.js
Or drop it inside a `<form>` and let it wire itself to the surrounding fields:
<form>
<script src="https://unpkg.com/form.js" data-key="pk_live_…"></script>
<input name="email">
<button type="submit">Sign up</button>
</form>
## Submission
Without a key, form.js is a thin controller around the surrounding form: it
emits lifecycle events and hands control back to your page. Set a Hanzo
publishable key on the script tag and it POSTs the serialized form to the Hanzo
forms ingest instead:
POST https://api.hanzo.ai/v1/forms
Authorization: Bearer pk_live_…
Content-Type: application/json
{ "email": "ada@example.com", "metadata": { "name": "Ada" } }
Script-tag options (all `data-*`):
| attribute | purpose |
| --------------- | ------------------------------------------------------ |
| `data-key` | publishable key (`pk_live_…` / `pk_test_…`) |
| `data-token` | `@hanzo/iam` access token (takes precedence over key) |
| `data-endpoint` | override the full submission URL |
| `data-host` | override the API host (default `https://api.hanzo.ai`) |
## Events
`window.Inform` is an event emitter — listen with `Inform.on` / `Inform.once`:
| event | `Inform.Events` | arguments |
| --------------------- | --------------- | ------------------------------------------------------- |
| `init-inform-script` | `InitScript` | the `<script>` element |
| `init-inform-form` | `InitForm` | the `<form>` element |
| `init-inform-inputs` | `InitInputs` | array of input/select nodes |
| `init-inform-submits` | `InitSubmits` | array of submit buttons |
| `inform-submit` | `OnSubmit` | unmanaged: `(done, event)`; managed: `(payload, event)` |
| `inform-done` | `OnDone` | the submit event, or the API response (managed) |
| `inform-error` | `OnError` | submission error (managed) |
In unmanaged mode, call `done()` from your `OnSubmit` listener to let the native
form submit proceed.
## License
[BSD][license-url]
+15050
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -1,6 +1,6 @@
{
"name": "form.js",
"version": "0.2.0",
"version": "0.3.0",
"description": "Automagic form validation and submission library.",
"main": "lib/form.cjs.js",
"module": "lib/form.es.js",
@@ -29,24 +29,24 @@
},
"homepage": "https://github.com/hanzo-io/form.js#readme",
"dependencies": {
"little-emitter": "^0.1.3"
"little-emitter": "^0.3.5"
},
"devDependencies": {
"cake-bundle": "^0.2.16",
"cake-outdated": "^0.1.16",
"cake-publish": "0.1.10",
"cake-version": "0.1.11",
"chai": "^3.1.0",
"coffee-script": "^1.9.3",
"connect": "^3.4.0",
"mocha": "^3.2.0",
"cake-bundle": "^0.2.21",
"cake-outdated": "^0.1.18",
"cake-publish": "^0.1.10",
"cake-version": "^0.1.15",
"chai": "^4.5.0",
"coffee-script": "^1.12.7",
"connect": "^3.7.0",
"mocha": "^3.5.3",
"phantomjs": "^2.1.7",
"requisite": "^1.9.16",
"selenium-standalone": "^6.1.0",
"serve-static": "^1.10.0",
"shortcake": "1.2.5",
"uglify-js": "^2.4.23",
"webdriverio": "^4.6.2"
"requisite": "^1.19.17",
"selenium-standalone": "^6.24.0",
"serve-static": "^1.16.3",
"shortcake": "^1.2.8",
"uglify-js": "^2.8.29",
"webdriverio": "^4.14.4"
},
"scripts": {
"prepublishOnly": "cake build",
+63 -365
View File
@@ -1,4 +1,5 @@
import Emitter from 'little-emitter'
import submit from './submit'
events =
InitScript: 'init-inform-script'
@@ -7,75 +8,108 @@ events =
InitSubmits: 'init-inform-submits'
OnSubmit: 'inform-submit'
OnDone: 'inform-done'
OnError: 'inform-error'
do ->
emitter = new Emitter
emitter.Events = events
emitter.events = events
# get this script tag
getLastTag = (selector)->
# find last script node
# last tag of a kind — the running <script> and the <form> that wraps it
getLastTag = (selector) ->
tags = document.getElementsByTagName selector
tags[tags.length - 1]
# last element is this tag
tag = tags[tags.length - 1]
return tag
# read a data-* attribute off an element, coercing true/false
attr = (el, name) ->
val = el?.getAttribute?('data-' + name)
return unless val?
val = val.trim()
switch val.toLowerCase()
when 'false' then false
when 'true' then true
else val
script = getLastTag 'script'
form = getLastTag 'form'
# {email, metadata} from a set of inputs — email detected by field name
serialize = (inputs) ->
data = email: '', metadata: {}
for input in inputs
name = (input.getAttribute('name') ? input.id ? '').trim().toLowerCase()
value = (input.value ? '').trim()
continue if name is '' or value is ''
if /email/.test name
data.email = value
else
data.metadata[name] = value
data
script = getLastTag 'script'
form = getLastTag 'form'
first = true
init = ->
if first
first = false
else
return
return unless first
first = false
# you need to get the attributes off these things manually
emitter.emit events.InitScript, script
emitter.emit events.InitForm, form
# submission target: publishable key (pk_…) or @hanzo/iam token, plus
# optional endpoint/host overrides — all off the script tag
config =
key: attr(script, 'key') ? attr(script, 'token')
endpoint: attr script, 'endpoint'
host: attr script, 'host'
submits = []
inputs = []
ins = form.getElementsByTagName 'input'
for input in ins
for input in form.getElementsByTagName 'input'
if input.getAttribute('type') != 'submit'
inputs.push input
else
submits.push input
ins = form.getElementsByTagName 'select'
for input in ins
for input in form.getElementsByTagName 'select'
inputs.push input
emitter.emit events.InitInputs, inputs
buttons = form.getElementsByTagName 'button'
for button in buttons
type = button.getAttribute('type')
if !type? || type == 'submit'
submits.push(button)
for button in form.getElementsByTagName 'button'
type = button.getAttribute 'type'
if !type? or type == 'submit'
submits.push button
emitter.emit events.InitSubmits, submits
# managed: form.js POSTs to the Hanzo forms ingest. Unmanaged (no key or
# endpoint configured): defer to the host, which calls done() to proceed.
managed = config.key? or config.endpoint?
# Called on successful submit
ondone = (event)->
ondone = (event) ->
emitter.emit events.OnDone, event
# Called when trying to submit
onsubmit = (event)->
if event.defaultPrevented
return
else
event.preventDefault()
onsubmit = (event) ->
return if event.defaultPrevented
event.preventDefault()
done = ()->
if managed
payload = serialize inputs
emitter.emit events.OnSubmit, payload, event
submit payload, config
.then (res) -> emitter.emit events.OnDone, res
.catch (err) -> emitter.emit events.OnError, err
return
done = ->
form.addEventListener 'submit', ondone
form.removeEventListener 'submit', onsubmit
setTimeout ()->
setTimeout ->
form.dispatchEvent new Event 'submit',
bubbles: false
cancelable: true
@@ -103,339 +137,3 @@ do ->
window.attachEvent 'onload', init
return emitter
# do ->
# `var endpoint = "%s", ml = %s`
# called = false
# errors = null
# forms = null
# handlers = null
# parent = null
# script = null
# selectors = {}
# validate = false
# XHR = ->
# xhr = null
# if window.XMLHttpRequest
# xhr = new XMLHttpRequest()
# else if window.ActiveXObject
# xhr = new ActiveXObject 'Microsoft.XMLHTTP'
# setHeaders: (headers) ->
# for k,v of headers
# xhr.setRequestHeader k, v
# return
# post: (url, headers, payload, cb) ->
# xhr.open 'POST', url, true
# @setHeaders headers
# xhr.send payload
# xhr.onreadystatechange = ->
# if xhr.readyState == 4
# if xhr.status == 200 or xhr.status == 201
# cb null, xhr.status, xhr
# else
# cb (new Error 'Subscription failed'), xhr.status, xhr
# return
# return
# # get form container
# getContainer = (script, selector = '')->
# if selector != ''
# document.querySelector selectors.container
# else
# parent = script.parentNode
# inputs = parent.getElementsByTagName 'input'
# if inputs.length < 1
# document.body
# else
# parent
# # get this script tag
# getScript = ->
# # find last script node
# scripts = document.getElementsByTagName( 'script' )
# # last element is this script tag
# script = scripts[ scripts.length - 1 ]
# script
# # Get elements from inside a parent
# getElements = (parent, selector) ->
# console.log 'getElements', parent, selector
# if selector? and selector != ''
# parent.querySelectorAll selector
# else
# [parent]
# # get value from a selector
# getValue = (parent = document.body, selector) ->
# el = parent.querySelector selector
# el?.value?.trim()
# # serialize a form
# serializeForm = (el) ->
# return {} unless el?
# data =
# metadata: {}
# inputs = el.getElementsByTagName 'input'
# # Loop over form elements
# for input in inputs
# # Clean up inputs
# k = input.name.trim().toLowerCase()
# v = input.value.trim()
# # Skip inputs we don't care about
# if k == '' or v == '' or (input.getAttribute 'type') == 'submit'
# continue
# # Detect emails
# if /email/.test k
# data.email = v
# else
# data.metadata[k] = v
# # Use selectors if necessary
# if selectors.email
# data.email = getValue el, selectors.email
# else
# data.email ?= ''
# for prop in ['firstname', 'lastname', 'name']
# if (selector = selectors[prop])?
# data.metadata[prop] = getValue el, selector
# console.error 'Email is required' if data.email == ''
# data
# # get setting off script tag data attribute
# attr = (name) ->
# val = script.getAttribute 'data-' + name
# return unless val?
# switch val.trim().toLowerCase()
# when 'false'
# false
# when 'true'
# true
# else
# val
# # Google event tracking code
# google =
# setup: ->
# return if window.ga? or window._gaq?
# ((i, s, o, g, r, a, m) ->
# i['GoogleAnalyticsObject'] = r
# i[r] = i[r] or ->
# (i[r].q = i[r].q or []).push arguments
# return
# i[r].l = 1 * new Date()
# a = s.createElement(o)
# m = s.getElementsByTagName(o)[0]
# a.async = 1
# a.src = g
# m.parentNode.insertBefore a, m
# return
# ) window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga'
# return
# track: (opts) ->
# return unless opts.category?
# google.setup()
# category = opts.category ? 'Subscription'
# action = opts.action ? opts.name ? 'Signup'
# label = opts.label ? 'Lead'
# value = opts.value ? 1
# if window._gaq?
# window._gaq.push ['_trackEvent', category, action, label, value]
# if window.ga?
# window.ga 'send', 'event', category, action, label, value
# return
# # Facebook event tracking
# facebook =
# setup: ->
# return if window._fbq?.loaded
# _fbq = window._fbq or (window._fbq = [])
# fbds = document.createElement('script')
# fbds.async = true
# fbds.src = '//connect.facebook.net/en_US/fbds.js'
# s = document.getElementsByTagName('script')[0]
# s.parentNode.insertBefore fbds, s
# _fbq.loaded = true
# return
# track: (opts) ->
# return unless opts.id?
# facebook.setup()
# value = opts.value ? '1.00'
# currency = opts.currency ? 'USD'
# window._fbq.push ['track', opts.id,
# value: value,
# currency: currency,
# ]
# return
# # Trigger event tracking
# track = ->
# facebook.track ml.facebook
# google.track ml.google
# return
# # Wire up submit handler
# addHandler = (el, errorEl) ->
# unless errorEl?
# errorEl = document.createElement 'div'
# errorEl.className = 'crowdstart-mailinglist-error'
# errorEl.style.display = 'none'
# errorEl.style.width = 100 + '%%'[0] # Prevents interpolation from picking up % as a thing needing interpolatin'
# el.appendChild errorEl
# showError = (msg) ->
# errorEl.style.display = 'inline'
# errorEl.innerHTML = msg
# false
# hideError = ->
# errorEl.style.display = 'none'
# thankYou = ->
# switch ml.thankyou.type
# when 'redirect'
# setTimeout ->
# window.location = ml.thankyou.url
# , 1000
# when 'html'
# el.innerHTML = ml.thankyou.html
# if document.createEvent && document.dispatchEvent
# event = document.createEvent 'Event'
# event.initEvent 'thankyou', true, true
# document.dispatchEvent event
# else
# console.log "Could not create or dispatch thankyou event"
# submitHandler = (ev) ->
# if ev.defaultPrevented
# return
# else
# ev.preventDefault()
# data = serializeForm el
# console.log data
# if ml.validate
# unless data.email?
# return showError 'Email is required'
# if (data.email.indexOf '@') == -1
# return showError 'Invalid email'
# if data.email.length < 3
# return showError 'Invalid email'
# hideError()
# payload = JSON.stringify data
# headers =
# 'X-Requested-With': 'XMLHttpRequest',
# 'Content-type': 'application/json; charset=utf-8'
# xhr = XHR()
# xhr.post endpoint, headers, payload, (err, status, xhr) ->
# return thankYou() if status == 409
# return showError(err) if err?
# # Fire tracking pixels
# track()
# thankYou()
# false
# (ev) ->
# el.removeEventListener 'submit', addHandler
# el.addEventListener 'submit', submitHandler
# setTimeout ->
# el.dispatchEvent new Event 'submit',
# bubbles: false
# cancelable: true
# , 500
# ev.preventDefault()
# false
# # Init all the things
# init = ->
# if called then return else called = true
# props = ['container', 'forms', 'submits', 'errors', 'email', 'firstname', 'lastname', 'name']
# for prop in props
# selectors[prop] = (attr prop) ? ml.selectors?[prop]
# # default selector for submit button
# selectors.submits ?= 'input[type="submit"]'
# # are we validating?
# ml.validate ?= (attr 'validate') ? false
# parent = getContainer script, selectors.container
# forms = getElements parent, selectors.forms
# handlers = getElements parent, selectors.submits
# # find error divs
# if selectors.errors
# errors = getElements parent, selectors.errors
# else
# errors = []
# console.log 'selectors', selectors
# console.log 'forms', forms
# console.log 'handlers', handlers
# console.log 'errors', errors
# for handler, i in handlers
# do (handler, i) ->
# return if handler.getAttribute 'data-hijacked'
# handler.setAttribute 'data-hijacked', true
# handler.addEventListener 'click', (addHandler forms[i], errors[i])
# handler.addEventListener 'submit', (addHandler forms[i], errors[i])
# # Get script tag, has to run before rest of DOM loads
# script = getScript()
# # Run init after DOM loads, attach various listeners
# if document.addEventListener
# document.addEventListener 'DOMContentLoaded', init, false
# else if document.attachEvent
# document.attachEvent 'onreadystatechange', ->
# init() if document.readyState == 'complete'
# if window.addEventListener
# window.addEventListener 'load', init, false
# else if window.attachEvent
# window.attachEvent 'onload', init
# return
+49
View File
@@ -0,0 +1,49 @@
# Hanzo forms ingest.
#
# POST https://api.hanzo.ai/v1/forms
#
# Auth: Authorization: Bearer <token>, where <token> is an @hanzo/iam access
# token when present, otherwise a publishable key (pk_live_… / pk_test_…). The
# server stamps org/tenant from the validated credential; the client only ships
# the serialized submission.
DEFAULT_HOST = 'https://api.hanzo.ai'
FORMS_PATH = '/v1/forms'
export host = DEFAULT_HOST
export path = FORMS_PATH
# POST a serialized submission. Returns a Promise that resolves on a 2xx
# response and rejects otherwise. `opts.token` (IAM access token) wins over
# `opts.key` (publishable key); `opts.endpoint` overrides host + path.
export default (data, opts = {}) ->
base = (opts.host ? DEFAULT_HOST).replace /\/+$/, ''
url = opts.endpoint ? base + FORMS_PATH
token = opts.token ? opts.key
body = JSON.stringify data
headers = 'Content-Type': 'application/json'
headers.Authorization = "Bearer #{token}" if token
if typeof fetch is 'function'
return fetch url,
method: 'POST'
headers: headers
body: body
credentials: 'include'
.then (res) ->
throw new Error "form.js: submission failed (#{res.status})" unless res.ok
res
new Promise (resolve, reject) ->
xhr = new XMLHttpRequest
xhr.open 'POST', url, true
xhr.withCredentials = true
xhr.setRequestHeader k, v for k, v of headers
xhr.onreadystatechange = ->
return unless xhr.readyState is 4
if 200 <= xhr.status < 300
resolve xhr
else
reject new Error "form.js: submission failed (#{xhr.status})"
xhr.send body
-28
View File
@@ -1,28 +0,0 @@
module.exports = class XHR
constructor: ->
xhr = null
if window.XMLHttpRequest
xhr = new XMLHttpRequest()
else if window.ActiveXObject
xhr = new ActiveXObject 'Microsoft.XMLHTTP'
setHeaders: (headers) ->
for k,v of headers
@xhr.setRequestHeader k, v
return
post: (url, headers, payload, cb) ->
@xhr.open 'POST', url, true
@setHeaders headers
@xhr.send payload
@xhr.onreadystatechange = =>
if @xhr.readyState == 4
if @xhr.status == 200 or @xhr.status == 201
cb null, @xhr.status, @xhr
else
cb (new Error 'Subscription failed'), @xhr.status, @xhr
return
return