###*
Changing page fragments programmatically
========================================
This module contains Unpoly's core functions to [change](/up.replace) or [destroy](/up.destroy)
page fragments via Javascript.
All the other Unpoly modules (like [`up.link`](/up.link) or [`up.modal`](/up.modal))
are based on this module.
@class up.flow
###
up.flow = (($) ->
u = up.util
setSource = (element, sourceUrl) ->
$element = $(element)
sourceUrl = u.normalizeUrl(sourceUrl) if u.isPresent(sourceUrl)
$element.attr("up-source", sourceUrl)
###*
Returns the URL the given element was retrieved from.
@method up.flow.source
@param {String|Element|jQuery} selectorOrElement
@experimental
###
source = (selectorOrElement) ->
$element = $(selectorOrElement).closest('[up-source]')
u.presence($element.attr("up-source")) || up.browser.url()
###*
Resolves the given selector (which might contain `&` references)
to an absolute selector.
@function up.flow.resolveSelector
@param {String|Element|jQuery} selectorOrElement
@param {String|Element|jQuery} origin
The element that this selector resolution is relative to.
That element's selector will be substituted for `&`.
@internal
###
resolveSelector = (selectorOrElement, origin) ->
if u.isString(selectorOrElement)
selector = selectorOrElement
if u.contains(selector, '&')
if origin
originSelector = u.selectorForElement(origin)
selector = selector.replace(/\&/, originSelector)
else
u.error("Found origin reference (%s) in selector %s, but options.origin is missing", '&', selector)
else
selector = u.selectorForElement(selectorOrElement)
selector
###*
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 UJS variant of this is the [`a[up-target]`](/a-up-target) selector.
\#\#\#\# Example
Let's say your curent 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.selector` 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='body']
The CSS selector to update if the server sends a non-200 status code.
@param {String} [options.title]
@param {String} [options.method='get']
@param {Object|Array} [options.data]
Parameters that should be sent as the request's payload.
Parameters can either be passed as an object (where the property names become
the param names and the property values become the param values) or as
an array of `{ name: 'param-name', value: 'param-value' }` objects
(compare to jQuery's [`serializeArray`](https://api.jquery.com/serializeArray/)).
@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 {String|Boolean} [options.source=true]
@param {String} [options.reveal=false]
Whether to [reveal](/up.reveal) the element being updated, by
scrolling its containing viewport.
@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 {Element|jQuery} [options.origin]
The element that triggered the replacement. The element's selector will
be substituted for the `&` shorthand in the target selector.
@param {String} [options.historyMethod='push']
@param {Object} [options.headers={}]
An object of additional header key/value pairs to send along
with the request.
@param {Boolean} [options.requireMatch=true]
Whether to raise an error if the given selector is missing in
either the current page or in the response.
@return {Promise}
A promise that will be resolved when the page has been updated.
@stable
###
replace = (selectorOrElement, url, options) ->
up.puts "Replacing %s from %s (%o)", selectorOrElement, url, options
options = u.options(options)
target = resolveSelector(selectorOrElement, options.origin)
failTarget = u.option(options.failTarget, 'body')
failTarget = resolveSelector(failTarget, options.origin)
if !up.browser.canPushState() && options.history != false
unless options.preload
up.browser.loadPage(url, u.only(options, 'method', 'data'))
return u.unresolvablePromise()
request =
url: url
method: options.method
data: options.data
target: target
failTarget: failTarget
cache: options.cache
preload: options.preload
headers: options.headers
promise = up.ajax(request)
promise.done (html, textStatus, xhr) ->
processResponse(true, target, url, request, xhr, options)
promise.fail (xhr, textStatus, errorThrown) ->
processResponse(false, failTarget, url, request, xhr, options)
promise
###*
@internal
###
processResponse = (isSuccess, selector, url, request, xhr, options) ->
options.method = u.normalizeMethod(u.option(u.methodFromXhr(xhr), options.method))
options.title = u.option(u.titleFromXhr(xhr), options.title)
isReloadable = (options.method == 'GET')
# The server can send us the current path using a header value.
# This way we know the actual URL if the server has redirected.
if urlFromServer = u.locationFromXhr(xhr)
url = urlFromServer
if isSuccess
newRequest =
url: url
method: u.methodFromXhr(xhr)
target: selector
up.proxy.alias(request, newRequest)
else if isReloadable
if query = u.requestDataAsQuery(options.data)
url = "#{url}?#{query}"
if isSuccess
if isReloadable # e.g. GET returns 200 OK
options.history = url unless options.history is false || u.isString(options.history)
options.source = url unless options.source is false || u.isString(options.source)
else # e.g. POST returns 200 OK
options.history = false unless u.isString(options.history)
options.source = 'keep' unless u.isString(options.source)
else
options.transition = options.failTransition
options.failTransition = undefined
if isReloadable # e.g. GET returns 500 Internal Server Error
options.history = url unless options.history is false
options.source = url unless options.source is false
else # e.g. POST returns 500 Internal Server Error
options.source = 'keep'
options.history = false
if options.preload
u.resolvedPromise()
else
extract(selector, xhr.responseText, options)
###*
Updates a selector on the current page with the
same selector from the given HTML string.
\#\#\#\# Example
Let's say your curent 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 resolved then the selector was updated
and all animation has finished.
@experimental
###
extract = (selectorOrElement, html, options) ->
up.log.group 'Extracting %s from %d bytes of HTML', selectorOrElement, html?.length, ->
options = u.options(options,
historyMethod: 'push',
requireMatch: true,
keep: true
)
selector = resolveSelector(selectorOrElement, options.origin)
response = parseResponse(html, options)
options.title ||= response.title()
up.layout.saveScroll() unless options.saveScroll == false
options.beforeSwap?()
deferreds = []
updateHistory(options)
for step in parseImplantSteps(selector, options)
up.log.group 'Updating %s', step.selector, ->
$old = findOldFragment(step.selector, options)
$new = response.find(step.selector)?.first()
if $old && $new
deferred = swapElements($old, $new, step.pseudoClass, step.transition, options)
deferreds.push(deferred)
options.afterSwap?()
up.motion.when(deferreds...)
findOldFragment = (selector, options) ->
# Prefer to replace fragments in an open popup or modal
first(".up-popup #{selector}") ||
first(".up-modal #{selector}") ||
first(selector) ||
oldFragmentNotFound(selector, options)
oldFragmentNotFound = (selector, options) ->
if options.requireMatch
message = 'Could not find selector %s in current body HTML'
if message[0] == '#'
message += ' (avoid using IDs)'
u.error(message, selector)
parseResponse = (html, options) ->
# jQuery cannot construct transient elements that contain or tags
htmlElement = u.createElementFromHtml(html)
title: -> htmlElement.querySelector("title")?.textContent
find: (selector) ->
# Although we cannot have a jQuery collection from an entire HTML document,
# we can use jQuery's Sizzle engine to grep through a DOM tree.
# jQuery.find is the Sizzle function (https://github.com/jquery/sizzle/wiki#public-api)
# which gives us non-standard CSS selectors such as `:has`.
# It returns an array of DOM elements, NOT a jQuery collection.
if child = $.find(selector, htmlElement)[0]
$(child)
else if options.requireMatch
u.error("Could not find selector %s in response %o", selector, html)
updateHistory = (options) ->
if options.history
document.title = options.title if options.title
up.history[options.historyMethod](options.history)
swapElements = ($old, $new, pseudoClass, transition, options) ->
transition ||= 'none'
if options.source == 'keep'
options = u.merge(options, source: source($old))
# Ensure that all transitions and animations have completed.
up.motion.finish($old)
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 = $new.contents().wrap('').parent()
# Note that since we're prepending/appending instead of replacing,
# `$new` will not actually be inserted into the DOM, only its children.
if pseudoClass == 'before'
$old.prepend($wrapper)
else
$old.append($wrapper)
hello($wrapper.children(), options)
# Reveal element that was being prepended/appended.
promise = up.layout.revealOrRestoreScroll($wrapper, options)
# Since we're adding content instead of replacing, we'll only
# animate $new instead of morphing between $old and $new
promise = promise.then -> up.animate($wrapper, transition, options)
# Remove the wrapper now that is has served it purpose
promise = promise.then -> u.unwrapElement($wrapper)
else if keepPlan = findKeepPlan($old, $new, options)
emitFragmentKept(keepPlan)
promise = u.resolvedPromise()
else
replacement = ->
options.keepPlans = transferKeepableElements($old, $new, options)
# Don't insert the new element after the old element. For some reason
# this will make the browser scroll to the bottom of the new element.
$new.insertBefore($old)
# Remember where the element came from so we can
# offer reload functionality.
setSource($new, options.source) unless options.source is false
autofocus($new)
# The fragment should be compiled before animating,
# so transitions see .up-current classes
hello($new, options)
# Morphing will also process options.reveal
up.morph($old, $new, transition, options)
# Wrap the replacement as a destroy animation, so $old will
# get marked as .up-destroying right away.
promise = destroy($old, animation: replacement)
promise
transferKeepableElements = ($old, $new, options) ->
keepPlans = []
if options.keep
for keepable in $old.find('[up-keep]')
$keepable = $(keepable)
if plan = findKeepPlan($keepable, $new, u.merge(options, descendantsOnly: true))
# Replace $keepable with its clone so it looks good in a transition between
# $old and $new. Note that $keepable will still point to the same element
# after the replacement, which is now detached.
$keepableClone = $keepable.clone()
$keepable.replaceWith($keepableClone)
# Since we're going to swap the entire $old and $new containers afterwards,
# replace the matching element with $keepable so it will eventually return to the DOM.
plan.$newElement.replaceWith($keepable)
keepPlans.push(plan)
keepPlans
findKeepPlan = ($element, $new, options) ->
if options.keep
$keepable = $element
if partnerSelector = u.castedAttr($keepable, 'up-keep')
u.isString(partnerSelector) or partnerSelector = '&'
partnerSelector = resolveSelector(partnerSelector, $keepable)
if options.descendantsOnly
$partner = $new.find(partnerSelector)
else
$partner = u.findWithSelf($new, partnerSelector)
$partner = $partner.first()
if $partner.length && $partner.is('[up-keep]')
description =
$element: $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 = u.merge(description, message: ['Keeping element %o', $keepable.get(0)])
if up.bus.nobodyPrevents('up:fragment:keep', keepEventArgs)
description
###*
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 `