diff --git a/README.md b/README.md
index df57a7b0..ba4e9729 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,8 @@ Name | Description
`style` | LESS or CSS to control the layout and style of the document using the variables from below. Can be a path to your own file or one of the following presets: `default`. May be an array of paths and/or presets.
`template` | Jade template to render HTML. Can be a path to your own file or one of the following presets: `default`.
`variables` | LESS variables that control theme colors, fonts, and spacing. Can be a path to your own file or one of the following presets: `default`, `flatly`, `slate`, `cyborg`. May be an array of paths and/or presets.
+`forms` | Whether to generate form fields for trying out the API (default is `false`).
+`forms-base-uri` | The base URI to prepend to relative action URIs when trying out the API. Default is `auto`, meaning the full URI is calculated from the HOST keyword in the API Blueprint document or when that isn't present, from the host serving the documentation.
**Note**: When using this theme programmatically, these options are cased like you would expect in Javascript: `--theme-full-width` becomes `options.themeFullWidth`.
diff --git a/src/main.coffee b/src/main.coffee
index e2c0bf04..00a164c6 100644
--- a/src/main.coffee
+++ b/src/main.coffee
@@ -317,11 +317,35 @@ modifyUriTemplate = (templateUri, parameters, colorize) ->
uri
, []).join('').replace(/\/+/g, '/')
-decorate = (api, md, slugCache, verbose) ->
+makeFullUriTemplate = (uriTemplate, baseUri) ->
+ # Change comma-separated params into individual params
+ while result = /\{([#&\?\+]?)([^,\}]+),/g.exec uriTemplate
+ operator = (if result[1] is '?' then '&' else result[1])
+ uriTemplate = uriTemplate.substring(0, result.index) +
+ '{' +
+ result[1] +
+ result[2] +
+ '}{' +
+ operator +
+ uriTemplate.substring(result.index + result[0].length)
+
+ # Prepend the baseUri if needed
+ if baseUri and uriTemplate.indexOf('://') is -1
+ if baseUri.charAt(baseUri.length-1) is '/'
+ baseUri = baseUri.substring 0, baseUri.length-1
+ if uriTemplate.charAt(0) isnt '/'
+ uriTemplate = '/' + uriTemplate
+ uriTemplate = baseUri + uriTemplate
+
+ return uriTemplate
+
+decorate = (api, md, slugCache, options) ->
# Decorate an API Blueprint AST with various pieces of information that
# will be useful for the theme. Anything that would significantly
# complicate the Jade template should probably live here instead!
+ verbose = options.verbose
+
# Use the slug caching mechanism
slugify = slug.bind slug, slugCache
@@ -394,23 +418,36 @@ decorate = (api, md, slugCache, verbose) ->
action.parameters = newParams.reverse()
+ uriTemplate = (action.attributes or {}).uriTemplate or
+ resource.uriTemplate or ''
+
+ for parameter in action.parameters
+ regex = new RegExp(
+ "\\{\\*#{parameter.name}\\}|\\{[#&\\?\\+]?#{parameter.name}\\*\\}")
+ parameter.explode = regex.test uriTemplate
+
# Set up the action's template URI
- action.uriTemplate = modifyUriTemplate(
- (action.attributes or {}).uriTemplate or resource.uriTemplate or '',
- action.parameters)
+ action.uriTemplate = modifyUriTemplate uriTemplate, action.parameters
- action.colorizedUriTemplate = modifyUriTemplate(
- (action.attributes or {}).uriTemplate or resource.uriTemplate or '',
+ action.colorizedUriTemplate = modifyUriTemplate(uriTemplate,
action.parameters, true)
+ host = options.themeFormsBaseUri
+ if host is 'auto'
+ host = api.host
+ action.fullUriTemplate = makeFullUriTemplate action.uriTemplate, host
+
# Examples have a content section only if they have a
# description, headers, body, or schema.
- action.hasRequest = false
+ action.requestCount = 0
+ action.hasBody = action.method == 'PUT' or action.method == 'POST'
for example in action.examples or []
for name in ['requests', 'responses']
for item in example[name] or []
- if name is 'requests' and not action.hasRequest
- action.hasRequest = true
+ if name is 'requests'
+ ++action.requestCount
+ if not action.hasBody and item.body
+ action.hasBody = true
# If there is no schema, but there are MSON attributes, then try
# to generate the schema. This will fail sometimes.
@@ -475,6 +512,12 @@ exports.getConfig = ->
description: 'Layout style name or path to custom stylesheet'},
{name: 'emoji', description: 'Enable support for emoticons',
boolean: true, default: true}
+ {name: 'forms',
+ description: 'Generate form fields for trying out the API',
+ boolean: true, default: false},
+ {name: 'forms-base-uri',
+ description: 'A base URI to use for trying out the API',
+ default: 'auto'}
]
# Render the blueprint with the given options using Jade and LESS
@@ -496,6 +539,8 @@ exports.render = (input, options, done) ->
options.themeTemplate ?= 'default'
options.themeCondenseNav ?= true
options.themeFullWidth ?= false
+ options.themeForms ?= false
+ options.themeFormsBaseUri ?= 'auto'
# Transform built-in layout names to paths
if options.themeTemplate is 'default'
@@ -527,7 +572,7 @@ exports.render = (input, options, done) ->
md.renderer.rules.code_block = md.renderer.rules.fence
benchmark.start 'decorate'
- decorate input, md, slugCache, options.verbose
+ decorate input, md, slugCache, options
benchmark.end 'decorate'
benchmark.start 'css-total'
@@ -541,6 +586,7 @@ exports.render = (input, options, done) ->
condenseNav: options.themeCondenseNav
css: css
fullWidth: options.themeFullWidth
+ forms: options.themeForms
date: moment
hash: (value) ->
crypto.createHash('md5').update(value.toString()).digest('hex')
diff --git a/styles/layout-default.less b/styles/layout-default.less
index 1d957a70..0f6093d9 100644
--- a/styles/layout-default.less
+++ b/styles/layout-default.less
@@ -121,6 +121,7 @@ pre {
border: 1px solid @code-block-border-color;
border-radius: @border-radius;
overflow: auto;
+ max-height: 50em;
code {
color: @code-block-text-color;
@@ -139,6 +140,34 @@ code {
border-radius: 3px;
}
+input.parameter, select.parameter, select.request {
+ width: 200px;
+}
+
+textarea.body {
+ width: 300px;
+ height: 100px;
+}
+
+button.tryit {
+ padding: 1px 4px;
+ font: inherit;
+ margin: 12px 0 12px 150px;
+}
+
+.spinner {
+ margin-left: 4px;
+ display: none;
+}
+
+.response {
+ display: none;
+}
+
+.click-to-fill {
+ cursor: pointer;
+}
+
ul, ol {
padding-left: 2em;
}
@@ -389,6 +418,10 @@ nav {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-in-out;
+
+ dd {
+ overflow: auto;
+ }
}
/* Layout classes */
diff --git a/templates/index.jade b/templates/index.jade
index b6c2fe9f..ea303ef0 100644
--- a/templates/index.jade
+++ b/templates/index.jade
@@ -5,6 +5,7 @@ include mixins.jade
html
head
meta(charset="utf-8")
+ meta(http-equiv="X-UA-Compatible", content="IE=edge")
title= self.api.name || 'API Documentation'
link(rel="stylesheet", href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css")
style!= self.css
diff --git a/templates/mixins.jade b/templates/mixins.jade
index 888811a9..7b551679 100644
--- a/templates/mixins.jade
+++ b/templates/mixins.jade
@@ -63,7 +63,7 @@ mixin Nav()
p(style="text-align: center; word-wrap: break-word;")
a(href=meta.value)= meta.value
-mixin Parameters(params)
+mixin Parameters(action)
//- Draw a definition list of parameter names, types, defaults,
//- examples and descriptions.
.title
@@ -72,9 +72,11 @@ mixin Parameters(params)
span.close Hide
span.open Show
.collapse-content
- dl.inner: each param in params || []
+ dl.inner: each param in action.parameters || []
dt= self.urldec(param.name)
dd
+ if self.forms
+ +ParameterField(param, action)
code= param.type || 'string'
|
if param.required
@@ -90,16 +92,124 @@ mixin Parameters(params)
if param.example
span.text-muted.example
strong Example:
- span= param.example
+ span(
+ class={'click-to-fill': self.forms}
+ data-fill-target=self.urldec(action.elementId + '-' + param.name)
+ )
+ = param.example
!= self.markdown(param.description)
if param.values.length
p.choices
strong Choices:
each value in param.values
- code= self.urldec(value.value)
+ code(
+ class={'click-to-fill': self.forms}
+ data-fill-target=self.urldec(action.elementId + '-' + param.name)
+ )
+ = self.urldec(value.value)
= ' '
-mixin RequestResponse(title, request, collapse)
+mixin ParameterField(param, action)
+ //- Generate an input field for the parameter to use
+ - var values = param.type === 'boolean' ? [{value: 'true'}, {value: 'false'}] : param.values
+ - values = (values && values.length) ? values : undefined
+ if values
+ select.parameter(
+ name=self.urldec(param.name)
+ id=action.elementId + '-' + param.name
+ required=param.required
+ multiple=param.explode
+ )
+ unless param.required
+ option
+ each option in values || []
+ option(
+ value=option.value
+ selected=option.value === param.default
+ )
+ = option.value
+ else
+ - var type = param.type === 'number' ? 'number' : 'text'
+ - var value = param.required ? param.default || param.example || '' : ''
+ input.parameter(
+ type=type
+ name=self.urldec(param.name)
+ id=action.elementId + '-' + param.name
+ value=value
+ required=param.required
+ )
+
+mixin FormFields(action)
+ input(
+ type='hidden'
+ name='__method'
+ value=action.method
+ )
+ input(
+ type='hidden'
+ name='__uri'
+ value=action.fullUriTemplate
+ )
+ - var body
+ each example in action.examples
+ each request, index in example.requests
+ - body = body || request.body
+ each item in request.headers
+ input(
+ type='hidden'
+ name='__' + index + '-header_' + item.name
+ value=item.value
+ )
+ +RequestIndexField(action)
+ if action.hasBody
+ dl.inner
+ dt Body
+ dd
+ textarea.body(
+ name='__body'
+ id=action.elementId + '-__body'
+ )= body
+
+mixin RequestIndexField(action)
+ if action.requestCount <= 1
+ input(
+ type='hidden'
+ name='__request'
+ value='0'
+ )
+ else
+ - var requestIndex = 0
+ dl.inner
+ dt Request
+ dd
+ select.request(name='__request')
+ each example in action.examples
+ each request in example.requests
+ option(
+ value=requestIndex
+ )= request.name || request.description || 'Request ' + (requestIndex + 1)
+ - requestIndex++
+
+mixin FormAction
+ button.tryit(name='__tryit', type='button') Try it out!
+ span.spinner: i.fa.fa-spinner.fa-pulse
+
+ .response.title
+ strong Server Response
+ .collapse-button.show
+ span.close Hide
+ span.open Show
+ .response.collapse-content
+ .inner
+ +FormResponseSection('Request URI', 'response-request-uri')
+ +FormResponseSection('Response Body', 'response-body')
+ +FormResponseSection('Response Headers', 'response-headers')
+
+mixin FormResponseSection(title, cls)
+ h5= title
+ pre: code(class=cls)
+
+mixin RequestResponse(title, action, request, collapse, clickToFill)
.title
strong
= title
@@ -110,9 +220,9 @@ mixin RequestResponse(title, request, collapse)
.collapse-button
span.close Hide
span.open Show
- +RequestResponseBody(request, collapse)
+ +RequestResponseBody(action, request, collapse, clickToFill)
-mixin RequestResponseBody(request, collapse, showBlank)
+mixin RequestResponseBody(action, request, collapse, clickToFill, showBlank)
if request.hasContent || showBlank
div(class=collapse ? 'collapse-content' : ''): .inner
if request.description
@@ -128,8 +238,12 @@ mixin RequestResponseBody(request, collapse, showBlank)
div(style="height: 1px;")
if request.body
h5 Body
- pre: code
- != self.highlight(request.body, null, ['json', 'yaml', 'xml', 'javascript'])
+ pre(
+ class={'click-to-fill': self.forms && clickToFill}
+ data-fill-target=action.elementId + '-__body'
+ )
+ code
+ != self.highlight(request.body, null, ['json', 'yaml', 'xml', 'javascript'])
div(style="height: 1px;")
if request.schema
h5 Schema
@@ -143,9 +257,9 @@ mixin RequestResponseBody(request, collapse, showBlank)
mixin Examples(resourceGroup, resource, action)
each example in action.examples
each request in example.requests
- +RequestResponse('Request', request, true)
+ +RequestResponse('Request', action, request, true, true)
each response in example.responses
- +RequestResponse('Response', response, true)
+ +RequestResponse('Response', action, response, true)
mixin Content()
//- Page header and API description
@@ -195,10 +309,15 @@ mixin Content()
//- A list of sub-sections for parameters, requests
//- and responses.
- if action.parameters.length
- +Parameters(action.parameters)
- if action.examples
- +Examples(resourceGroup, resource, action)
+ form(accept-charset='UTF-8')
+ if action.parameters.length
+ +Parameters(action)
+ if self.forms
+ +FormFields(action)
+ if self.forms
+ +FormAction
+ if action.examples
+ +Examples(resourceGroup, resource, action)
mixin ContentTriple()
.middle
@@ -245,7 +364,7 @@ mixin ContentTriple()
span.hostname= self.api.host
!= action.colorizedUriTemplate
.tabs
- if action.hasRequest
+ if action.requestCount > 0
.example-names
span Requests
- var requestCount = 0
@@ -256,7 +375,7 @@ mixin ContentTriple()
each example in action.examples
each request in example.requests
.tab
- +RequestResponseBody(request, false, true)
+ +RequestResponseBody(action, request, false, true, true)
.tabs
.example-names
span Responses
@@ -264,7 +383,7 @@ mixin ContentTriple()
span.tab-button= response.name
each response in example.responses
.tab
- +RequestResponseBody(response, false, true)
+ +RequestResponseBody(action, response, false, false, true)
else
each example in action.examples
.tabs
@@ -274,7 +393,7 @@ mixin ContentTriple()
span.tab-button= response.name
each response in example.responses
.tab
- +RequestResponseBody(response, false, true)
+ +RequestResponseBody(action, response, false, false, true)
.middle
.action(class=action.methodLower, id=action.elementId)
h4.action-heading
@@ -287,7 +406,11 @@ mixin ContentTriple()
//- A list of sub-sections for parameters, requests
//- and responses.
- if action.parameters.length
- +Parameters(action.parameters)
+ form(accept-charset='UTF-8')
+ if action.parameters.length
+ +Parameters(action)
+ if self.forms
+ +FormFields(action)
+ +FormAction
hr.split
diff --git a/templates/scripts.js b/templates/scripts.js
index d105df6c..c320fef8 100644
--- a/templates/scripts.js
+++ b/templates/scripts.js
@@ -2,6 +2,13 @@
/* eslint quotes: [2, "single"] */
'use strict';
+/*
+ Determine if a string starts with another string.
+*/
+function startsWith(str, prefix) {
+ return str.indexOf(prefix) === 0;
+}
+
/*
Determine if a string ends with another string.
*/
@@ -117,6 +124,177 @@ function toggleCollapseNav(event, force) {
}
}
+/*
+ Get a value for the given field and operator.
+*/
+function createFieldValue(field, operator, explosive) {
+ if (operator === '*') {
+ explosive = true;
+ operator = false;
+ }
+ var fieldValue, values, i;
+ if (explosive) {
+ fieldValue = '';
+ if (field.tagName.toLowerCase() === 'select' && field.options) {
+ values = [];
+ for (i = 0; i < field.options.length; i++) {
+ var option = field.options[i];
+ if (option.selected) {
+ values.push(option.value || option.text);
+ }
+ }
+ } else {
+ values = field.value.split(/\s*,\s*/g);
+ }
+ for (i = 0; i < values.length; i++) {
+ fieldValue += createFieldValue({name: field.name, value: values[i]}, operator);
+ if (operator === '?') {
+ operator = '&';
+ }
+ }
+ return fieldValue;
+ }
+
+ fieldValue = encodeURIComponent(field.value);
+ if (operator && (fieldValue || field.required)) {
+ if (operator === '?' || operator === '&') {
+ fieldValue = operator + field.name + '=' + fieldValue;
+ } else if (operator === '#') {
+ fieldValue = '#' + fieldValue;
+ } else if (operator === '+') {
+ fieldValue = field.value;
+ }
+ }
+ return fieldValue;
+}
+
+/*
+ Send a sandbox request to the server.
+*/
+function sendSandboxRequest(event) {
+ var i, body, regex, regexResult, fieldValue;
+ var button = event.target;
+ var fields = button.form.elements;
+ var method = fields.__method.value;
+ var uri = fields.__uri.value;
+ var headerPrefix = '__' + fields.__request.value + '-header_';
+ var headers = [];
+ var xhr = new XMLHttpRequest();
+ xhr.withCredentials = true;
+
+ if (uri.indexOf('://') === -1 && window.location.hostname) {
+ if (uri.charAt(0) !== '/') {
+ uri = '/' + uri;
+ }
+ if (window.location.port) {
+ uri = window.location.protocol + '//' + window.location.hostname + ':' + window.location.port + uri;
+ } else {
+ uri = window.location.protocol + '//' + window.location.hostname + uri;
+ }
+ }
+
+ if (fields.__body) {
+ body = fields.__body.value;
+ }
+
+ for (i = 0; i < fields.length; i++) {
+ if (startsWith(fields[i].name, '__')) {
+ if (startsWith(fields[i].name, headerPrefix)) {
+ headers.push({
+ name: fields[i].name.substring(headerPrefix.length),
+ value: fields[i].value
+ });
+ }
+ } else {
+ regex = new RegExp('\\{([#\\+\\?&]?)' + fields[i].name + '(\\*?)\\}');
+ regexResult = regex.exec(uri);
+ if (!regexResult) {
+ continue;
+ }
+ var operator = regexResult[1];
+ if (operator === '&' && uri.indexOf('?') === -1) {
+ operator = '?';
+ }
+ fieldValue = createFieldValue(fields[i], operator, regexResult[2]);
+ uri = uri.substring(0, regexResult.index) + fieldValue + uri.substring(regexResult.index + regexResult[0].length);
+ }
+ }
+ button.form.querySelector('.response-request-uri').textContent = uri;
+
+ xhr.onload = handleSandboxResponse.bind(null, xhr, button);
+ xhr.onerror = xhr.onload;
+ xhr.open(method, uri);
+ for (i = 0; i < headers.length; i++) {
+ xhr.setRequestHeader(headers[i].name, headers[i].value);
+ }
+ if (!window.__jwt && storageAvailable('sessionStorage')) {
+ window.__jwt = sessionStorage.getItem('__jwt');
+ }
+ if (window.__jwt) {
+ xhr.setRequestHeader('Authorization', 'Bearer ' + window.__jwt);
+ }
+
+ button.querySelector('.spinner').style.display = 'inline-block';
+ xhr.send(body);
+}
+
+/*
+ Handle a sandbox response from the server, showing its output.
+*/
+function handleSandboxResponse(xhr, button) {
+ var content = button.form.querySelector('.response.collapse-content');
+ var inner = content.children[0];
+ var responseBody = button.form.querySelector('.response-body');
+ var responseHeaders = button.form.querySelector('.response-headers');
+
+ button.querySelector('.spinner').style.display = 'none';
+ button.form.querySelector('.response.title').style.display = 'block';
+ content.style.display = 'block';
+
+ try {
+ responseBody.textContent = JSON.stringify(JSON.parse(xhr.responseText), null, 2);
+ } catch (e) {
+ responseBody.textContent = xhr.responseText;
+ }
+ responseHeaders.textContent = xhr.getAllResponseHeaders();
+ window.__jwt = xhr.getResponseHeader('X-Set-JWT') || window.__jwt;
+ if (window.__jwt && storageAvailable('sessionStorage')) {
+ sessionStorage.setItem('__jwt', window.__jwt);
+ }
+
+ content.style.maxHeight = inner.offsetHeight + 12 + 'px';
+}
+
+/*
+ Detect whether web storage is available.
+*/
+function storageAvailable(type) {
+ try {
+ var storage = window[type],
+ x = '__storage_test__';
+ storage.setItem(x, x);
+ storage.removeItem(x);
+ return true;
+ }
+ catch(e) {
+ return false;
+ }
+}
+
+/*
+ Fill a field with the content of a text element.
+*/
+function fillField(fieldName, event) {
+ var element = event.target;
+ while (element && !element.dataset.fillTarget) {
+ element = element.parentNode;
+ }
+ var field = document.getElementById(fieldName);
+ if (field && element) {
+ field.value = element.textContent;
+ }
+}
+
/*
Refresh the page after a live update from the server. This only
works in live preview mode (using the `--server` parameter).
@@ -211,6 +389,17 @@ function init() {
// Show all by default
toggleCollapseNav({target: navItems[i].children[0]});
}
+
+ // Set up the sandbox functionality
+ var tryitButtons = document.querySelectorAll('button.tryit');
+ for (i = 0; i < tryitButtons.length; i++) {
+ tryitButtons[i].onclick = sendSandboxRequest;
+ }
+ var clickToFills = document.querySelectorAll('.click-to-fill');
+ for (i = 0; i < clickToFills.length; i++) {
+ clickToFills[i].title = clickToFills[i].title || 'Click to fill the field with this value';
+ clickToFills[i].onclick = fillField.bind(null, clickToFills[i].dataset.fillTarget);
+ }
}
// Initial call to set up buttons
diff --git a/test/forms.coffee b/test/forms.coffee
new file mode 100644
index 00000000..5c9fc8b9
--- /dev/null
+++ b/test/forms.coffee
@@ -0,0 +1,372 @@
+{assert} = require 'chai'
+theme = require '../lib/main'
+
+describe 'Forms', ->
+ it 'Should not generate form fields when disabled', (done) ->
+
+ theme.render ast, {themeForms: false}, (err, html) ->
+ if err then return done err
+
+ assert.notInclude html, '
+
+ theme.render ast, {themeForms: true, themeFormsBaseUri: 'http://example.com/rest/'}, (err, html) ->
+ if err then return done err
+ assert.include html, ''
+ done()
+
+ it 'Should use the HOST metadata when there is no formsBaseUri option', (done) ->
+
+ theme.render ast, {themeForms: true}, (err, html) ->
+ if err then return done err
+ assert.include html, ''
+ done()
+
+ it 'Should generate form fields', (done) ->
+
+ theme.render ast, {themeForms: true}, (err, html) ->
+ if err then return done err
+
+ # GET
+
+ expectSelect html, 'boolean', true, ['true', 'false']
+ expectClickToFill html, 'boolean', 'true'
+
+ expectSelect html, 'optional_boolean', false, ['', 'true', 'false']
+ expectClickToFill html, 'optional_boolean', 'true'
+
+ expectInput html, 'number', 'number', true, 123
+ expectClickToFill html, 'number', 123
+
+ expectInput html, 'optional', 'text'
+ expectClickToFill html, 'optional', 'example'
+
+ expectInput html, 'default', 'text', true, 'default'
+ expectNoClickToFill html, 'default'
+
+ expectInput html, 'example', 'text', true, 'example'
+ expectClickToFill html, 'example', 'example'
+
+ expectInput html, 'default_example', 'text', true, 'default'
+ expectClickToFill html, 'default_example', 'example'
+
+ expectSelect html, 'enum', true, ['one', 'two']
+ expectClickToFill html, 'enum', 'one', 'code'
+ expectClickToFill html, 'enum', 'two', 'code'
+ expectClickToFill html, 'enum', 'two'
+
+ expectSelect html, 'optional_enum', false, ['', 'one', 'two']
+ expectClickToFill html, 'optional_enum', 'one', 'code'
+ expectClickToFill html, 'optional_enum', 'two', 'code'
+ expectClickToFill html, 'optional_enum', 'two'
+
+ expectSelect html, 'explode_enum', true, ['one', 'two'], true
+ expectClickToFill html, 'explode_enum', 'one', 'code'
+ expectClickToFill html, 'explode_enum', 'two', 'code'
+ expectClickToFill html, 'explode_enum', 'one'
+
+ # POST
+
+ assert.include html, ''
+ assert.include html, ''
+
+ done()
+
+
+expectSelect = (html, name, required, options, multiple) ->
+ required = if required then ' required' else ''
+ multiple = if multiple then ' multiple' else ''
+ assert.include html, ""
+
+createOptions = (options) ->
+ result = ''
+ for option in options
+ if option
+ result += ""
+ else
+ result += ""
+ return result
+
+expectInput = (html, name, type, required, value) ->
+ required = if required then ' required' else ''
+ assert.include html, ""
+
+expectClickToFill = (html, name, example, tag) ->
+ tag ?= 'span'
+ assert.include html, "<#{tag} data-fill-target=\"test-get-#{name}\" class=\"click-to-fill\">#{example}#{tag}>"
+
+expectNoClickToFill = (html, name) ->
+ assert.notInclude html, "