зеркало из https://github.com/github/remote-form.git
This commit is contained in:
Родитель
2c58f9cc9d
Коммит
28f63c93d3
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"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": {
|
||||
"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) 2018 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.
|
44
README.md
44
README.md
|
@ -1 +1,45 @@
|
|||
# remote-form
|
||||
|
||||
A function that will enable submitting it over AJAX
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
$ npm install --save @github/remote-form
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import {remoteForm} from '@github/remote-form'
|
||||
```
|
||||
|
||||
```html
|
||||
<form action="/signup" method="post">
|
||||
<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
|
||||
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
npm install
|
||||
npm test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the MIT license. See LICENSE for details.
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"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": "eslint src/ test/ && flow check",
|
||||
"prebuild": "npm run clean && npm run lint",
|
||||
"build": "rollup -c",
|
||||
"pretest": "npm run build",
|
||||
"test": "karma start test/karma.config.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"decorator",
|
||||
"remote-form",
|
||||
"form"
|
||||
],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.4.0",
|
||||
"babel-preset-github": "^3.2.0",
|
||||
"chai": "^4.2.0",
|
||||
"eslint": "^5.15.3",
|
||||
"eslint-plugin-github": "^2.0.0",
|
||||
"flow-bin": "^0.95.1",
|
||||
"karma": "^4.0.1",
|
||||
"karma-chai": "^0.1.0",
|
||||
"karma-chrome-launcher": "^2.2.0",
|
||||
"karma-mocha": "^1.3.0",
|
||||
"karma-mocha-reporter": "^2.2.5",
|
||||
"mocha": "^6.0.2",
|
||||
"rollup": "^1.7.3",
|
||||
"rollup-plugin-babel": "^4.3.2",
|
||||
"rollup-plugin-commonjs": "^9.2.2",
|
||||
"rollup-plugin-node-resolve": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"form-data-entries": "^1.0.2",
|
||||
"selector-set": "^1.1.4"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
/* @flow */
|
||||
module.exports = require('eslint-plugin-github/prettier.config')
|
|
@ -0,0 +1,29 @@
|
|||
/* @flow */
|
||||
|
||||
import babel from 'rollup-plugin-babel'
|
||||
import resolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
|
||||
const pkg = require('./package.json')
|
||||
|
||||
export default {
|
||||
input: 'src/index.js',
|
||||
output: [
|
||||
{
|
||||
file: pkg['module'],
|
||||
format: 'es'
|
||||
},
|
||||
{
|
||||
file: pkg['main'],
|
||||
format: 'umd',
|
||||
name: 'remoteForm'
|
||||
}
|
||||
],
|
||||
plugins: [
|
||||
babel({
|
||||
presets: ['github']
|
||||
}),
|
||||
resolve(),
|
||||
commonjs()
|
||||
]
|
||||
}
|
|
@ -0,0 +1,233 @@
|
|||
/* @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 (res.status < 300) {
|
||||
return res
|
||||
} else {
|
||||
throw new ErrorWithResponse('request failed', res)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"rules": {
|
||||
"flowtype/require-valid-file-annotation": "off"
|
||||
},
|
||||
"env": {
|
||||
"mocha": true
|
||||
},
|
||||
"globals": {
|
||||
"assert": true
|
||||
},
|
||||
"extends": "../.eslintrc.json"
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
function checker(request, response, next) {
|
||||
if (request.method === 'POST' && request.url === '/ok') {
|
||||
response.setHeader('content-type', 'text/html')
|
||||
response.setHeader('x-html-safe', 'NOT_EVEN_NONCE')
|
||||
response.writeHead(200)
|
||||
// eslint-disable-next-line github/unescaped-html-literal
|
||||
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)
|
||||
// eslint-disable-next-line github/unescaped-html-literal
|
||||
response.end('<b>Hello</b> world!')
|
||||
return
|
||||
} else if (request.method === 'GET' && request.url === '/ok?a=b&query=hello') {
|
||||
response.writeHead(200)
|
||||
// eslint-disable-next-line github/unescaped-html-literal
|
||||
response.end('<b>Hello</b> world!')
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = function(config) {
|
||||
config.set({
|
||||
frameworks: ['mocha', 'chai'],
|
||||
files: ['../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()
|
||||
})
|
||||
})
|
Загрузка…
Ссылка в новой задаче