###**
Fragment update API
===================
The `up.fragment` module exposes a high-level Javascript API to [update](/up.replace) or
[destroy](/up.destroy) page fragments.
Fragments are [compiled](/up.compiler) elements that can be updated from a server URL.
They also exist on a layer (page, modal, popup).
Most of Unpoly's functionality (like [fragment links](/up.link) or [modals](/up.modal))
is built from `up.fragment` functions. You may use them to extend Unpoly from your
[custom Javascript](/up.syntax).
@module up.fragment
###
up.fragment = do ->
u = up.util
e = up.element
###**
Configures defaults for fragment insertion.
@property up.fragment.config
@param {string} [options.fallbacks=['body']]
When a fragment updates cannot find the requested element, Unpoly will try this list of alternative selectors.
The first selector that matches an element in the current page (or response) will be used.
If the response contains none of the selectors, an error message will be shown.
It is recommend to always keep `'body'` as the last selector in the last in the case
your server or load balancer renders an error message that does not contain your
application layout.
@param {string} [options.fallbackTransition=null]
The transition to use when using a [fallback target](/#options.fallbacks).
By default this is not set and the original replacement's transition is used.
@stable
###
config = new up.Config
fallbacks: ['body']
fallbackTransition: null
reset = ->
config.reset()
setSource = (element, sourceUrl) ->
unless sourceUrl is false
sourceUrl = u.normalizeUrl(sourceUrl) if u.isPresent(sourceUrl)
element.setAttribute("up-source", sourceUrl)
###**
Returns the URL the given element was retrieved from.
@method up.fragment.source
@param {string|Element|jQuery} selectorOrElement
@experimental
###
source = (selectorOrElement) ->
element = e.get(selectorOrElement)
if element = e.closest(element, '[up-source]')
element.getAttribute("up-source")
else
up.browser.url()
###**
Replaces elements on the current page with corresponding elements
from a new page fetched from the server.
The current and new elements must both match the given CSS selector.
The unobtrusive variant of this is the [`a[up-target]`](/a-up-target) selector.
\#\#\# Example
Let's say your current HTML looks like this:
old one
old two
We now replace the second `
`:
up.replace('.two', '/new')
The server renders a response for `/new`:
new one
new two
Unpoly looks for the selector `.two` in the response and [implants](/up.extract) it into
the current page. The current page now looks like this:
old one
new two
Note how only `.two` has changed. The update for `.one` was
discarded, since it didn't match the selector.
\#\#\# Appending or prepending instead of replacing
By default Unpoly will replace the given selector with the same
selector from a freshly fetched page. Instead of replacing you
can *append* the loaded content to the existing content by using the
`:after` pseudo selector. In the same fashion, you can use `:before`
to indicate that you would like the *prepend* the loaded content.
A practical example would be a paginated list of items:
Wash car
Purchase supplies
Fix tent
In order to append more items from a URL, replace into
the `.tasks:after` selector:
up.replace('.tasks:after', '/page/2')
\#\#\# Setting the window title from the server
If the `replace` call changes history, the document title will be set
to the contents of a `` tag in the response.
The server can also change the document title by setting
an `X-Up-Title` header in the response.
\#\#\# Optimizing response rendering
The server is free to optimize Unpoly requests by only rendering the HTML fragment
that is being updated. The request's `X-Up-Target` header will contain
the CSS selector for the updating fragment.
If you are using the `unpoly-rails` gem you can also access the selector via
`up.target` in all controllers, views and helpers.
\#\#\# Events
Unpoly will emit [`up:fragment:destroyed`](/up:fragment:destroyed) on the element
that was replaced and [`up:fragment:inserted`](/up:fragment:inserted) on the new
element that replaces it.
@function up.replace
@param {string|Element|jQuery} selectorOrElement
The CSS selector to update. You can also pass a DOM element or jQuery element
here, in which case a selector will be inferred from the element's class and ID.
@param {string} url
The URL to fetch from the server.
@param {string} [options.failTarget]
The CSS selector to update if the server sends a non-200 status code.
@param {string} [options.fallback]
The selector to update when the original target was not found in the page.
@param {string} [options.title]
The document title after the replacement.
If the call pushes an history entry and this option is missing, the title is extracted from the response's `` tag.
You can also pass `false` to explicitly prevent the title from being updated.
@param {string} [options.method='get']
The HTTP method to use for the request.
@param {Object|FormData|string|Array} [options.params]
[Parameters](/up.Params) that should be sent as the request's payload.
@param {string} [options.transition='none']
@param {string|boolean} [options.history=true]
If a string is given, it is used as the URL the browser's location bar and history.
If omitted or true, the `url` argument will be used.
If set to `false`, the history will remain unchanged.
@param {boolean|string} [options.source=true]
@param {boolean|string} [options.reveal=false]
Whether to [reveal](/up.reveal) the new fragment.
You can also pass a CSS selector for the element to reveal.
@param {boolean|string} [options.failReveal=false]
Whether to [reveal](/up.reveal) the new fragment when the server responds with an error.
You can also pass a CSS selector for the element to reveal.
@param {number} [options.revealPadding]
@param {boolean} [options.restoreScroll=false]
If set to true, Unpoly will try to restore the scroll position
of all the viewports around or below the updated element. The position
will be reset to the last known top position before a previous
history change for the current URL.
@param {boolean} [options.cache]
Whether to use a [cached response](/up.proxy) if available.
@param {string} [options.historyMethod='push']
@param {Object} [options.headers={}]
An object of additional header key/value pairs to send along
with the request.
@param {Element|jQuery} [options.origin]
The element that triggered the replacement.
The element's selector will be substituted for the `&` shorthand in the target selector ([like in Sass](https://sass-lang.com/documentation/file.SASS_REFERENCE.html#parent-selector)).
@param {string} [options.layer='auto']
The name of the layer that ought to be updated. Valid values are
`'auto'`, `'page'`, `'modal'` and `'popup'`.
If set to `'auto'` (default), Unpoly will try to find a match in the
same layer as the element that triggered the replacement (see `options.origin`).
If that element is not known, or no match was found in that layer,
Unpoly will search in other layers, starting from the topmost layer.
@param {string} [options.failLayer='auto']
The name of the layer that ought to be updated if the server sends a non-200 status code.
@param {boolean} [options.keep=true]
Whether this replacement will preserve [`[up-keep]`](/up-keep) elements.
@param {boolean} [options.hungry=true]
Whether this replacement will update [`[up-hungry]`](/up-hungry) elements.
@return {Promise}
A promise that will be fulfilled when the page has been updated.
@stable
###
replace = (selectorOrElement, url, options) ->
options = u.options(options)
options.inspectResponse = fullLoad = -> up.browser.navigate(url, u.only(options, 'method', 'params'))
if !up.browser.canPushState() && options.history != false
fullLoad() unless options.preload
return u.unresolvablePromise()
successOptions = u.merge options,
humanizedTarget: 'target'
failureOptions = u.merge options,
humanizedTarget: 'failure target'
provideTarget: undefined # don't provide a target if we're targeting the failTarget
restoreScroll: false
u.renameKey(failureOptions, 'failTransition', 'transition')
u.renameKey(failureOptions, 'failLayer', 'layer')
u.renameKey(failureOptions, 'failReveal', 'reveal')
try
improvedTarget = bestPreflightSelector(selectorOrElement, successOptions)
improvedFailTarget = bestPreflightSelector(options.failTarget, failureOptions)
catch error
# Since we're an async function, we should not throw exceptions but return a rejected promise.
# http://2ality.com/2016/03/promise-rejections-vs-exceptions.html
return Promise.reject(error)
requestAttrs = u.only options,
'method',
'data', # deprecated
'params',
'cache',
'preload',
'headers',
'timeout'
u.assign requestAttrs,
url: url
target: improvedTarget
failTarget: improvedFailTarget
request = new up.Request(requestAttrs)
onSuccess = (response) ->
processResponse(true, improvedTarget, request, response, successOptions)
onFailure = (response) ->
rejection = -> Promise.reject(response)
if response.isFatalError()
rejection()
else
promise = processResponse(false, improvedFailTarget, request, response, failureOptions)
# Although processResponse() we will perform a successful replacement of options.failTarget,
# we still want to reject the promise that's returned to our API client.
u.always(promise, rejection)
promise = up.request(request)
promise = promise.then(onSuccess, onFailure) unless options.preload
promise
###**
@internal
###
processResponse = (isSuccess, selector, request, response, options) ->
sourceUrl = response.url
historyUrl = sourceUrl
if hash = request.hash
options.hash = hash
historyUrl += hash
isReloadable = (response.method == 'GET')
if isSuccess
if isReloadable # e.g. GET returns 200 OK
options.history = historyUrl unless options.history is false || u.isString(options.history)
options.source = sourceUrl unless options.source is false || u.isString(options.source)
else # e.g. POST returns 200 OK
# We allow the developer to pass GETable URLs as { history } and { source } options.
options.history = false unless u.isString(options.history)
options.source = 'keep' unless u.isString(options.source)
else
if isReloadable # e.g. GET returns 500 Internal Server Error
options.history = historyUrl unless options.history is false
options.source = sourceUrl unless options.source is false
else # e.g. POST returns 500 Internal Server Error
options.history = false
options.source = 'keep'
if shouldExtractTitle(options) && response.title
options.title = response.title
extract(selector, response.text, options)
shouldExtractTitle = (options) ->
not (options.title is false || u.isString(options.title) || (options.history is false && options.title isnt true))
###**
Updates a selector on the current page with the
same selector from the given HTML string.
\#\#\# Example
Let's say your current HTML looks like this:
old one
old two
We now replace the second `
`, using an HTML string
as the source:
html = '
new one
' +
'
new two
';
up.extract('.two', html)
Unpoly looks for the selector `.two` in the strings and updates its
contents in the current page. The current page now looks like this:
old one
new two
Note how only `.two` has changed. The update for `.one` was
discarded, since it didn't match the selector.
@function up.extract
@param {string|Element|jQuery} selectorOrElement
@param {string} html
@param {Object} [options]
See options for [`up.replace()`](/up.replace).
@return {Promise}
A promise that will be fulfilled then the selector was updated
and all animation has finished.
@stable
###
extract = (selectorOrElement, html, options) ->
up.log.group 'Extracting %s from %d bytes of HTML', selectorOrElement, html?.length, ->
options = u.options options,
historyMethod: 'push'
keep: true
layer: 'auto'
up.viewport.saveScroll() unless options.saveScroll == false
u.rejectOnError ->
# Allow callers to create the targeted element right before we swap.
options.provideTarget?()
responseDoc = new up.HtmlParser(html)
extractSteps = bestMatchingSteps(selectorOrElement, responseDoc, options)
if shouldExtractTitle(options) && responseTitle = responseDoc.title()
options.title = responseTitle
updateHistoryAndTitle(options)
swapPromises = []
for step in extractSteps
up.log.group 'Swapping fragment %s', step.selector, ->
# Note that we must copy the options hash instead of changing it in-place, since the
# async swapElements() is scheduled for the next microtask and we must not change the options
# for the previous iteration.
swapOptions = u.merge(options, u.only(step, 'origin', 'reveal'))
responseDoc.prepareForInsertion(step.newElement)
swapPromise = swapElements(step.oldElement, step.newElement, step.pseudoClass, step.transition, swapOptions)
swapPromises.push(swapPromise)
# Delay all further links in the promise chain until all fragments have been swapped
Promise.all(swapPromises)
bestPreflightSelector = (selectorOrElement, options) ->
cascade = new up.ExtractCascade(selectorOrElement, options)
cascade.bestPreflightSelector()
bestMatchingSteps = (selectorOrElement, response, options) ->
options = u.merge(options, { response })
cascade = new up.ExtractCascade(selectorOrElement, options)
cascade.bestMatchingSteps()
updateHistoryAndTitle = (options) ->
options = u.options(options, historyMethod: 'push')
up.history[options.historyMethod](options.history) if options.history
document.title = options.title if u.isString(options.title)
swapElements = (oldElement, newElement, pseudoClass, transition, options) ->
transition ||= 'none'
# When the server responds with an error, or when the request method is not
# reloadable (not GET), we keep the same source as before.
if options.source == 'keep'
options = u.merge(options, source: source(oldElement))
# Remember where the element came from in case someone needs to up.reload(newElement) later.
setSource(newElement, options.source)
if pseudoClass
# Text nodes are wrapped in a .up-insertion container so we can
# animate them and measure their position/size for scrolling.
# This is not possible for container-less text nodes.
wrapper = e.createFromSelector('.up-insertion')
while childNode = newElement.firstChild
wrapper.appendChild(childNode)
# Note that since we're prepending/appending instead of replacing,
# newElement will not actually be inserted into the DOM, only its children.
if pseudoClass == 'before'
oldElement.insertAdjacentElement('afterbegin', wrapper)
else
oldElement.insertAdjacentElement('beforeend', wrapper)
for child in wrapper.children
hello(child, options)
# Reveal element that was being prepended/appended.
# Since we will animate (not morph) it's OK to allow animation of scrolling
# if options.scrollBehavior is given.
promise = up.viewport.scrollAfterInsertFragment(wrapper, options)
# Since we're adding content instead of replacing, we'll only
# animate newElement instead of morphing between oldElement and newElement
promise = u.always(promise, up.animate(wrapper, transition, options))
# Remove the wrapper now that is has served it purpose
promise = promise.then -> e.unwrap(wrapper)
return promise
else if keepPlan = findKeepPlan(oldElement, newElement, options)
# Since we're keeping the element that was requested to be swapped,
# there is nothing left to do here, except notify event listeners.
emitFragmentKept(keepPlan)
return Promise.resolve()
else
# This needs to happen before prepareClean() below.
# Otherwise we would collect destructors for elements we want to keep.
options.keepPlans = transferKeepableElements(oldElement, newElement, options)
parent = oldElement.parentNode
morphOptions = u.merge options,
beforeStart: ->
markElementAsDestroying(oldElement)
afterInsert: ->
up.hello(newElement, options)
beforeDetach: ->
up.syntax.clean(oldElement)
afterDetach: ->
e.remove(oldElement) # clean up jQuery data
emitFragmentDestroyed(oldElement, parent: parent, log: false)
return up.morph(oldElement, newElement, transition, morphOptions)
# This will find all [up-keep] descendants in oldElement, overwrite their partner
# element in newElement and leave a visually identical clone in oldElement for a later transition.
# Returns an array of keepPlans.
transferKeepableElements = (oldElement, newElement, options) ->
keepPlans = []
if options.keep
for keepable in oldElement.querySelectorAll('[up-keep]')
if plan = findKeepPlan(keepable, newElement, u.merge(options, descendantsOnly: true))
# plan.oldElement is now keepable
# Replace keepable with its clone so it looks good in a transition between
# oldElement and newElement. Note that keepable will still point to the same element
# after the replacement, which is now detached.
keepableClone = keepable.cloneNode(true)
e.replace(keepable, keepableClone)
# Since we're going to swap the entire oldElement and newElement containers afterwards,
# replace the matching element with keepable so it will eventually return to the DOM.
e.replace(plan.newElement, keepable)
keepPlans.push(plan)
keepPlans
findKeepPlan = (element, newElement, options) ->
if options.keep
keepable = element
if partnerSelector = e.booleanOrStringAttr(keepable, 'up-keep')
u.isString(partnerSelector) or partnerSelector = '&'
partnerSelector = e.resolveSelector(partnerSelector, keepable)
if options.descendantsOnly
partner = e.first(newElement, partnerSelector)
else
partner = e.subtree(newElement, partnerSelector)[0]
if partner && e.matches(partner, '[up-keep]')
plan =
oldElement: keepable # the element that should be kept
newElement: partner # the element that would have replaced it but now does not
newData: up.syntax.data(partner) # the parsed up-data attribute of the element we will discard
keepEventArgs =
target: keepable
newFragment: partner
newData: plan.newData
log: ['Keeping element %o', keepable]
if up.event.nobodyPrevents('up:fragment:keep', keepEventArgs)
plan
###**
Elements with an `up-keep` attribute will be persisted during
[fragment updates](/a-up-target).
For example:
The element you're keeping should have an umambiguous class name, ID or `up-id`
attribute so Unpoly can find its new position within the page update.
Emits events [`up:fragment:keep`](/up:fragment:keep) and [`up:fragment:kept`](/up:fragment:kept).
\#\#\# Controlling if an element will be kept
Unpoly will **only** keep an existing element if:
- The existing element has an `up-keep` attribute
- The response contains an element matching the CSS selector of the existing element
- The matching element *also* has an `up-keep` attribute
- The [`up:fragment:keep`](/up:fragment:keep) event that is [emitted](/up.emit) on the existing element
is not prevented by a event listener.
Let's say we want only keep an `