Merge pull request #1 from github/init

Init
This commit is contained in:
Kristján Oddsson 2019-05-13 15:44:30 +01:00 коммит произвёл GitHub
Родитель 2c58f9cc9d 12b08beba0
Коммит 234f942ecf
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
12 изменённых файлов: 7447 добавлений и 0 удалений

18
.babelrc Normal file
Просмотреть файл

@ -0,0 +1,18 @@
{
"env": {
"esm": {
"presets": ["github"]
},
"umd": {
"plugins": [
["@babel/plugin-transform-modules-umd", {
"globals": {
"selector-set": "SelectorSet"
}
}]
],
"moduleId": "remoteForm",
"presets": ["github"]
}
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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