зеркало из https://github.com/github/remote-form.git
Коммит
234f942ecf
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"env": {
|
||||
"esm": {
|
||||
"presets": ["github"]
|
||||
},
|
||||
"umd": {
|
||||
"plugins": [
|
||||
["@babel/plugin-transform-modules-umd", {
|
||||
"globals": {
|
||||
"selector-set": "SelectorSet"
|
||||
}
|
||||
}]
|
||||
],
|
||||
"moduleId": "remoteForm",
|
||||
"presets": ["github"]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"extends": [
|
||||
"plugin:github/es6",
|
||||
"plugin:github/browser",
|
||||
"plugin:github/flow"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "test/**/*.js",
|
||||
"rules": {
|
||||
"flowtype/require-valid-file-annotation": "off",
|
||||
"github/unescaped-html-literal": "off",
|
||||
"eslint-comments/no-use": "off"
|
||||
},
|
||||
"env": {
|
||||
"mocha": true
|
||||
},
|
||||
"globals": {
|
||||
"assert": true,
|
||||
"remoteForm": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
[ignore]
|
||||
|
||||
[include]
|
||||
|
||||
[libs]
|
||||
|
||||
[lints]
|
||||
|
||||
[options]
|
||||
|
||||
[strict]
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
node_modules/
|
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2019 GitHub, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
74
README.md
74
README.md
|
@ -1 +1,75 @@
|
|||
# remote-form
|
||||
|
||||
A function that will enable submitting forms over AJAX.
|
||||
|
||||
The function will make a request based on the form using `window.fetch` with the payload encoded as URL parameters if the form method is a `GET` and `FormData` for all the other methods.
|
||||
|
||||
The request object is available in the callback function, allowing the headers and body to be modified before the request is sent.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
$ npm install --save @github/remote-form
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import {remoteForm} from '@github/remote-form'
|
||||
|
||||
// Make all forms that have the `data-remote` attribute a remote form.
|
||||
remoteForm('form[data-remote]', async function(form, wants, request) {
|
||||
// Before we start the request
|
||||
form.classList.remove('has-error')
|
||||
form.classList.add('is-loading')
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await wants.html()
|
||||
} catch (error) {
|
||||
// If the request errored, we'll set the error state and return.
|
||||
form.classList.remove('is-loading')
|
||||
form.classList.add('has-error')
|
||||
return
|
||||
}
|
||||
|
||||
// If the request succeeded we can do something with the results.
|
||||
form.classList.remove('is-loading')
|
||||
form.querySelector('.results').innerHTML = response.html
|
||||
})
|
||||
```
|
||||
|
||||
```html
|
||||
<form action="/signup" method="post" data-remote>
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" />
|
||||
|
||||
<label for="password">Username</label>
|
||||
<input id="password" type="password" />
|
||||
|
||||
<button>Log in</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Browser support
|
||||
|
||||
Browsers without native [custom element support][support] require a [polyfill][].
|
||||
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
|
||||
[support]: https://caniuse.com/#feat=custom-elementsv1
|
||||
[polyfill]: https://github.com/webcomponents/custom-elements
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
npm install
|
||||
npm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the MIT license. See LICENSE for details.
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "@github/remote-form",
|
||||
"version": "0.0.0",
|
||||
"description": "Decorator that will submit a form over AJAX",
|
||||
"repository": "github/remote-form",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.umd.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "github-lint",
|
||||
"prebuild": "npm run clean && npm run lint && mkdir dist",
|
||||
"build-umd": "BABEL_ENV=umd babel src/index.js -o dist/index.umd.js",
|
||||
"build-esm": "BABEL_ENV=esm babel src/index.js -o dist/index.esm.js",
|
||||
"build": "npm run build-umd && npm run build-esm",
|
||||
"pretest": "npm run build",
|
||||
"test": "karma start test/karma.config.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"decorator",
|
||||
"remote-form",
|
||||
"form"
|
||||
],
|
||||
"eslintIgnore": ["dist/"],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.1.5",
|
||||
"@babel/core": "^7.4.4",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.4.4",
|
||||
"@babel/plugin-transform-modules-umd": "^7.2.0",
|
||||
"babel-preset-github": "^3.2.0",
|
||||
"chai": "^4.2.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-github": "^2.0.0",
|
||||
"flow-bin": "^0.98.1",
|
||||
"karma": "^4.1.0",
|
||||
"karma-chai": "^0.1.0",
|
||||
"karma-chrome-launcher": "^2.2.0",
|
||||
"karma-mocha": "^1.3.0",
|
||||
"karma-mocha-reporter": "^2.2.5",
|
||||
"mocha": "^6.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"form-data-entries": "^1.0.2",
|
||||
"selector-set": "^1.1.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
/* @flow strict */
|
||||
module.exports = require('eslint-plugin-github/prettier.config')
|
|
@ -0,0 +1,234 @@
|
|||
/* @flow strict */
|
||||
|
||||
import SelectorSet from 'selector-set'
|
||||
import formDataEntries from 'form-data-entries'
|
||||
|
||||
// Parse HTML text into document fragment.
|
||||
function parseHTML(document: Document, html: string): DocumentFragment {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = html
|
||||
return document.importNode(template.content, true)
|
||||
}
|
||||
|
||||
function serialize(form: HTMLFormElement): string {
|
||||
const params = new URLSearchParams()
|
||||
for (const [name, value] of formDataEntries(form)) {
|
||||
params.append(name, value)
|
||||
}
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
class ErrorWithResponse extends Error {
|
||||
response: SimpleResponse
|
||||
|
||||
constructor(message, response) {
|
||||
super(message)
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeferred<T>(): [Promise<T>, () => T, () => T] {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise(function(_resolve, _reject) {
|
||||
resolve = _resolve
|
||||
reject = _reject
|
||||
})
|
||||
|
||||
// eslint-disable-next-line flowtype/no-flow-fix-me-comments
|
||||
// $FlowFixMe
|
||||
return [promise, resolve, reject]
|
||||
}
|
||||
|
||||
type SimpleRequest = {
|
||||
method: string,
|
||||
url: string,
|
||||
body: ?FormData,
|
||||
headers: Headers
|
||||
}
|
||||
|
||||
export type SimpleResponse = {
|
||||
url: string,
|
||||
status: number,
|
||||
statusText: ?string,
|
||||
headers: Headers,
|
||||
text: string,
|
||||
// eslint-disable-next-line flowtype/no-weak-types
|
||||
json: {[string]: any},
|
||||
html: DocumentFragment
|
||||
}
|
||||
|
||||
type Kicker = {
|
||||
text: () => Promise<SimpleResponse>,
|
||||
json: () => Promise<SimpleResponse>,
|
||||
html: () => Promise<SimpleResponse>
|
||||
}
|
||||
|
||||
export type CallbackFormat = (form: HTMLFormElement, kicker: Kicker, req: SimpleRequest) => void | Promise<void>
|
||||
|
||||
let selectorSet: ?SelectorSet<CallbackFormat>
|
||||
|
||||
const afterHandlers = []
|
||||
const beforeHandlers = []
|
||||
|
||||
export function afterRemote(fn: (form: HTMLFormElement) => mixed) {
|
||||
afterHandlers.push(fn)
|
||||
}
|
||||
|
||||
export function beforeRemote(fn: (form: HTMLFormElement) => mixed) {
|
||||
beforeHandlers.push(fn)
|
||||
}
|
||||
|
||||
export function remoteForm(selector: string, fn: CallbackFormat) {
|
||||
if (!selectorSet) {
|
||||
selectorSet = new SelectorSet()
|
||||
document.addEventListener('submit', handleSubmit)
|
||||
}
|
||||
selectorSet.add(selector, fn)
|
||||
}
|
||||
|
||||
export function remoteUninstall(selector: string, fn: CallbackFormat) {
|
||||
if (selectorSet) {
|
||||
selectorSet.remove(selector, fn)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: Event) {
|
||||
if (!(event.target instanceof HTMLFormElement)) {
|
||||
return
|
||||
}
|
||||
const form = event.target
|
||||
const matches = selectorSet && selectorSet.matches(form)
|
||||
if (!matches || matches.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const req = buildRequest(form)
|
||||
const [kickerPromise, ultimateResolve, ultimateReject] = makeDeferred()
|
||||
|
||||
event.preventDefault()
|
||||
processHandlers(matches, form, req, kickerPromise).then(
|
||||
async performAsyncSubmit => {
|
||||
if (performAsyncSubmit) {
|
||||
for (const handler of beforeHandlers) {
|
||||
await handler(form)
|
||||
}
|
||||
|
||||
// TODO: ensure that these exceptions are processed by our global error handler
|
||||
remoteSubmit(req)
|
||||
.then(ultimateResolve, ultimateReject)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
for (const handler of afterHandlers) {
|
||||
handler(form)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// No handler called the kicker function
|
||||
form.submit()
|
||||
}
|
||||
},
|
||||
err => {
|
||||
// TODO: special "cancel" error object to halt processing and avoid
|
||||
// submitting the form
|
||||
form.submit()
|
||||
setTimeout(() => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Process each handler sequentially until it either completes or calls the
|
||||
// kicker function.
|
||||
async function processHandlers(
|
||||
matches: Array<*>,
|
||||
form: HTMLFormElement,
|
||||
req: SimpleRequest,
|
||||
kickerPromise: Promise<SimpleResponse>
|
||||
): Promise<boolean> {
|
||||
let kickerWasCalled = false
|
||||
for (const match of matches) {
|
||||
const [kickerCalled, kickerCalledResolve] = makeDeferred()
|
||||
const kick = () => {
|
||||
kickerWasCalled = true
|
||||
kickerCalledResolve()
|
||||
return kickerPromise
|
||||
}
|
||||
const kicker: Kicker = {
|
||||
text: kick,
|
||||
json: () => {
|
||||
req.headers.set('Accept', 'application/json')
|
||||
return kick()
|
||||
},
|
||||
html: () => {
|
||||
req.headers.set('Accept', 'text/html')
|
||||
return kick()
|
||||
}
|
||||
}
|
||||
await Promise.race([kickerCalled, match.data.call(null, form, kicker, req)])
|
||||
}
|
||||
return kickerWasCalled
|
||||
}
|
||||
|
||||
function buildRequest(form: HTMLFormElement): SimpleRequest {
|
||||
const req: SimpleRequest = {
|
||||
method: form.method || 'GET',
|
||||
url: form.action,
|
||||
headers: new Headers({'X-Requested-With': 'XMLHttpRequest'}),
|
||||
body: null
|
||||
}
|
||||
|
||||
if (req.method.toUpperCase() === 'GET') {
|
||||
const data = serialize(form)
|
||||
if (data) {
|
||||
req.url += (~req.url.indexOf('?') ? '&' : '?') + data
|
||||
}
|
||||
} else {
|
||||
req.body = new FormData(form)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
async function remoteSubmit(req): Promise<SimpleResponse> {
|
||||
const response = await window.fetch(req.url, {
|
||||
method: req.method,
|
||||
body: req.body !== null ? req.body : undefined,
|
||||
headers: req.headers,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
|
||||
const res: SimpleResponse = {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
text: '',
|
||||
get json() {
|
||||
// eslint-disable-next-line no-shadow
|
||||
const response: SimpleResponse = this
|
||||
const data = JSON.parse(response.text)
|
||||
delete response.json
|
||||
response.json = data
|
||||
return response.json
|
||||
},
|
||||
get html() {
|
||||
// eslint-disable-next-line no-shadow
|
||||
const response: SimpleResponse = this
|
||||
delete response.html
|
||||
|
||||
response.html = parseHTML(document, response.text)
|
||||
return response.html
|
||||
}
|
||||
}
|
||||
|
||||
const body = await response.text()
|
||||
res.text = body
|
||||
|
||||
if (response.ok) {
|
||||
return res
|
||||
} else {
|
||||
throw new ErrorWithResponse('request failed', res)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
function checker(request, response, next) {
|
||||
if (request.method === 'POST' && request.url === '/ok') {
|
||||
response.setHeader('content-type', 'text/html')
|
||||
response.writeHead(200)
|
||||
response.end('<b>Hello</b> world!')
|
||||
return
|
||||
} else if (request.method === 'POST' && request.url === '/server-error') {
|
||||
response.writeHead(500)
|
||||
response.end('{"message": "Server error!"}')
|
||||
return
|
||||
} else if (request.method === 'GET' && request.url === '/ok?query=hello') {
|
||||
response.writeHead(200)
|
||||
response.end('<b>Hello</b> world!')
|
||||
return
|
||||
} else if (request.method === 'GET' && request.url === '/ok?a=b&query=hello') {
|
||||
response.writeHead(200)
|
||||
response.end('<b>Hello</b> world!')
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = function(config) {
|
||||
config.set({
|
||||
frameworks: ['mocha', 'chai'],
|
||||
files: [
|
||||
'../node_modules/form-data-entries/index.umd.js',
|
||||
'../node_modules/selector-set/selector-set.js',
|
||||
'../dist/index.umd.js',
|
||||
'test.js'
|
||||
],
|
||||
reporters: ['mocha'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
browsers: ['ChromeHeadless'],
|
||||
autoWatch: false,
|
||||
singleRun: true,
|
||||
concurrency: Infinity,
|
||||
middleware: ['checker'],
|
||||
plugins: [
|
||||
'karma-*',
|
||||
{
|
||||
'middleware:checker': ['value', checker]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
const {remoteForm: _remoteForm, remoteUninstall} = window.remoteForm
|
||||
|
||||
describe('remoteForm', function() {
|
||||
let htmlForm
|
||||
|
||||
beforeEach(function() {
|
||||
document.body.innerHTML = `
|
||||
<form action="/ok" class="my-remote-form remote-widget" method="post" target="submit-fallback">
|
||||
<input name="query" value="hello">
|
||||
<button type="submit">Submit<button>
|
||||
</form>
|
||||
|
||||
<iframe name="submit-fallback" style="display: none"></iframe>
|
||||
<meta name="html-safe-nonce" content="NOT_EVEN_NONCE">
|
||||
`
|
||||
|
||||
htmlForm = document.querySelector('form')
|
||||
})
|
||||
|
||||
const installed = []
|
||||
|
||||
function remoteForm(selector, fn) {
|
||||
installed.push([selector, fn])
|
||||
_remoteForm(selector, fn)
|
||||
}
|
||||
|
||||
afterEach(function() {
|
||||
for (const [selector, fn] of installed) {
|
||||
remoteUninstall(selector, fn)
|
||||
}
|
||||
installed.length = 0
|
||||
})
|
||||
|
||||
it('submits the form with fetch', function(done) {
|
||||
remoteForm('.my-remote-form', async function(form, wants, req) {
|
||||
assert.ok(req.url.endsWith('/ok'))
|
||||
assert.instanceOf(req.body, FormData)
|
||||
|
||||
const response = await wants.html()
|
||||
assert.ok(form.matches('.my-remote-form'))
|
||||
assert.ok(response.html.querySelector('b'))
|
||||
done()
|
||||
})
|
||||
|
||||
document.querySelector('button[type=submit]').click()
|
||||
})
|
||||
|
||||
it('server failure scenario', function(done) {
|
||||
htmlForm.action = 'server-error'
|
||||
|
||||
remoteForm('.my-remote-form', async function(form, wants) {
|
||||
try {
|
||||
await wants.html()
|
||||
assert.ok(false, 'should not resolve')
|
||||
} catch (error) {
|
||||
assert.equal(error.response.status, 500)
|
||||
assert.equal(error.response.json['message'], 'Server error!')
|
||||
done()
|
||||
}
|
||||
})
|
||||
|
||||
document.querySelector('button[type=submit]').click()
|
||||
})
|
||||
|
||||
it('chained handlers', function(done) {
|
||||
let previousCallbackCalled = false
|
||||
remoteForm('.remote-widget', async function() {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
previousCallbackCalled = true
|
||||
})
|
||||
|
||||
remoteForm('.my-remote-form', async function() {
|
||||
if (previousCallbackCalled) {
|
||||
done()
|
||||
} else {
|
||||
done(new Error('The previous remote form callback was not called'))
|
||||
}
|
||||
})
|
||||
|
||||
document.querySelector('button[type=submit]').click()
|
||||
})
|
||||
|
||||
it('exception in js handlers results in form submitting normally', async function() {
|
||||
remoteForm('.remote-widget', function() {
|
||||
throw new Error('ignore me')
|
||||
})
|
||||
|
||||
remoteForm('.my-remote-form', async function(form, wants) {
|
||||
try {
|
||||
await wants.text()
|
||||
assert.ok(false, 'should never happen')
|
||||
} catch (error) {
|
||||
assert.ok(true)
|
||||
}
|
||||
})
|
||||
|
||||
function errorHandler(event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
const originalMochaError = window.onerror
|
||||
window.onerror = function() {}
|
||||
window.addEventListener('error', errorHandler)
|
||||
|
||||
document.querySelector('button[type=submit]').click()
|
||||
|
||||
const iframe = await new Promise(resolve => {
|
||||
document.querySelector('iframe[name=submit-fallback]').addEventListener('load', event => resolve(event.target))
|
||||
})
|
||||
window.onerror = originalMochaError
|
||||
window.removeEventListener('error', errorHandler)
|
||||
assert.match(iframe.contentWindow.location.href, /\/ok$/)
|
||||
})
|
||||
|
||||
it('GET form serializes data to URL', function(done) {
|
||||
remoteForm('.my-remote-form', async function(form, wants, req) {
|
||||
assert.isNull(req.body)
|
||||
await wants.html()
|
||||
done()
|
||||
})
|
||||
|
||||
const button = document.querySelector('button[type=submit]')
|
||||
button.form.method = 'GET'
|
||||
button.click()
|
||||
})
|
||||
|
||||
it('GET form serializes data to URL with existing query', function(done) {
|
||||
remoteForm('.my-remote-form', async function(form, wants) {
|
||||
await wants.html()
|
||||
done()
|
||||
})
|
||||
|
||||
const button = document.querySelector('button[type=submit]')
|
||||
button.form.method = 'GET'
|
||||
button.form.action += '?a=b'
|
||||
button.click()
|
||||
})
|
||||
})
|
Загрузка…
Ссылка в новой задаче