This commit is contained in:
Kristján Oddsson 2019-03-27 11:33:13 +00:00
Родитель 2c58f9cc9d
Коммит 28f63c93d3
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: F5C58CF9F8FE5D63
13 изменённых файлов: 7276 добавлений и 0 удалений

23
.eslintrc.json Normal file
Просмотреть файл

@ -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
}
}
]
}

11
.flowconfig Normal file
Просмотреть файл

@ -0,0 +1,11 @@
[ignore]
[include]
[libs]
[lints]
[options]
[strict]

2
.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,2 @@
dist/
node_modules/

19
LICENSE Executable file
Просмотреть файл

@ -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.

Просмотреть файл

@ -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.

6669
package-lock.json сгенерированный Executable file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

48
package.json Executable file
Просмотреть файл

@ -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"
}
}

2
prettier.config.js Executable file
Просмотреть файл

@ -0,0 +1,2 @@
/* @flow */
module.exports = require('eslint-plugin-github/prettier.config')

29
rollup.config.js Executable file
Просмотреть файл

@ -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()
]
}

233
src/index.js Normal file
Просмотреть файл

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

12
test/.eslintrc.json Executable file
Просмотреть файл

@ -0,0 +1,12 @@
{
"rules": {
"flowtype/require-valid-file-annotation": "off"
},
"env": {
"mocha": true
},
"globals": {
"assert": true
},
"extends": "../.eslintrc.json"
}

47
test/karma.config.js Executable file
Просмотреть файл

@ -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]
}
]
})
}

137
test/test.js Normal file
Просмотреть файл

@ -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()
})
})