diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..3d30e392 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,35 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake +# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby + +name: Build + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + - name: Setup Firefox + uses: browser-actions/setup-firefox@v1.1.0 + - name: Setup Geckodriver + uses: browser-actions/setup-geckodriver@v0.0.0 + - name: Start xvfb + run: /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + - name: Run tests + run: bundle exec rake ci + env: + DISPLAY: ":99.0" + DRIVER: "selenium" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8f490df8..00000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -addons: - firefox: "54.0.1" - -dist: trusty -language: ruby - -cache: - directories: - - "travis_geckodriver" - -rvm: - - '2.4.1' - -before_script: - - if [ ! -d $PWD/travis_geckodriver ]; then mkdir $PWD/travis_geckodriver; fi - - if [ ! -f $PWD/travis_geckodriver/geckodriver ]; then wget https://github.com/mozilla/geckodriver/releases/download/v0.16.0/geckodriver-v0.16.0-linux64.tar.gz; fi - - if [ ! -f $PWD/travis_geckodriver/geckodriver ]; then tar -xvf geckodriver-v0.16.0-linux64.tar.gz -C $PWD/travis_geckodriver; fi - - "export PATH=$PWD/travis_geckodriver:$PATH" - - "geckodriver --version" - - "export DISPLAY=:99.0" - - "sh -e /etc/init.d/xvfb start" - - sleep 5 # give xvfb some time to start - -script: bundle exec rake ci diff --git a/Gemfile.lock b/Gemfile.lock index f2209a86..ce2376c5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,6 @@ PATH remote: . specs: turbograft (0.4.8) - coffee-rails GEM remote: https://rubygems.org/ @@ -87,20 +86,12 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) coderay (1.1.3) - coffee-rails (5.0.0) - coffee-script (>= 2.2.0) - railties (>= 5.2.0) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.12.2) concurrent-ruby (1.2.2) crass (1.0.6) daemons (1.4.1) date (3.3.3) erubi (1.12.0) eventmachine (1.2.7) - execjs (2.9.0) globalid (1.2.1) activesupport (>= 6.1) i18n (1.14.1) @@ -209,6 +200,7 @@ GEM timeout (0.4.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + webrick (1.8.1) websocket (1.2.9) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) @@ -238,6 +230,7 @@ DEPENDENCIES teaspoon-mocha thin turbograft! + webrick BUNDLED WITH 2.4.8 diff --git a/README.md b/README.md index 887ae802..fd283392 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Turbograft was built with simplicity in mind. It intends to offer the smallest a ## Status [![Gem Version](https://badge.fury.io/rb/turbograft.svg)](http://badge.fury.io/rb/turbograft) -[![Build Status](https://travis-ci.org/Shopify/turbograft.svg?branch=master)](http://travis-ci.org/Shopify/turbograft) +[![Build](https://github.com/Shopify/turbograft/actions/workflows/build.yml/badge.svg)](https://github.com/Shopify/turbograft/actions/workflows/build.yml) ## Installation @@ -189,9 +189,10 @@ There is an example app that you can boot to play with TurboGraft. Open the con When turbograft replaces or removes a node it uses native DOM API to do so. If any objects use jQuery to listen to events on a node then these objects will leak when the node is replaced because jQuery will still have references to it. To clean these up you'll need to tell jQuery that they're removed. This can be done with something like: -```coffeescript -document.addEventListener 'page:after-node-removed', (event) -> - $(event.data).remove() +```js +document.addEventListener('page:after-node-removed', function(event) { + $(event.data).remove(); +}); ``` ## Contributing diff --git a/lib/assets/javascripts/turbograft.coffee b/lib/assets/javascripts/turbograft.coffee deleted file mode 100644 index 4da49323..00000000 --- a/lib/assets/javascripts/turbograft.coffee +++ /dev/null @@ -1,30 +0,0 @@ -#= require_self -#= require_tree ./turbograft - -window.TurboGraft ?= { handlers: {} } - -TurboGraft.tgAttribute = (attr) -> - tgAttr = if attr[0...3] == 'tg-' - "data-#{attr}" - else - "data-tg-#{attr}" - -TurboGraft.getTGAttribute = (node, attr) -> - tgAttr = TurboGraft.tgAttribute(attr) - node.getAttribute(tgAttr) || node.getAttribute(attr) - -TurboGraft.removeTGAttribute = (node, attr) -> - tgAttr = TurboGraft.tgAttribute(attr) - node.removeAttribute(tgAttr) - node.removeAttribute(attr) - -TurboGraft.hasTGAttribute = (node, attr) -> - tgAttr = TurboGraft.tgAttribute(attr) - node.hasAttribute(tgAttr) || node.hasAttribute(attr) - -TurboGraft.querySelectorAllTGAttribute = (node, attr, value = null) -> - tgAttr = TurboGraft.tgAttribute(attr) - if value - node.querySelectorAll("[#{tgAttr}=#{value}], [#{attr}=#{value}]") - else - node.querySelectorAll("[#{tgAttr}], [#{attr}]") diff --git a/lib/assets/javascripts/turbograft.js b/lib/assets/javascripts/turbograft.js new file mode 100644 index 00000000..3ec686cf --- /dev/null +++ b/lib/assets/javascripts/turbograft.js @@ -0,0 +1,37 @@ +//= require_self +//= require_tree ./turbograft + +if (window.TurboGraft == null) { window.TurboGraft = { handlers: {} }; } + +TurboGraft.tgAttribute = function(attr) { + if (attr.slice(0, 3) === 'tg-') { + return `data-${attr}`; + } else { + return `data-tg-${attr}`; + } +}; + +TurboGraft.getTGAttribute = function(node, attr) { + const tgAttr = TurboGraft.tgAttribute(attr); + return node.getAttribute(tgAttr) || node.getAttribute(attr); +}; + +TurboGraft.removeTGAttribute = function(node, attr) { + const tgAttr = TurboGraft.tgAttribute(attr); + node.removeAttribute(tgAttr); + node.removeAttribute(attr); +}; + +TurboGraft.hasTGAttribute = function(node, attr) { + const tgAttr = TurboGraft.tgAttribute(attr); + return node.hasAttribute(tgAttr) || node.hasAttribute(attr); +}; + +TurboGraft.querySelectorAllTGAttribute = function(node, attr, value = null) { + const tgAttr = TurboGraft.tgAttribute(attr); + if (value) { + return node.querySelectorAll(`[${tgAttr}=${value}], [${attr}=${value}]`); + } else { + return node.querySelectorAll(`[${tgAttr}], [${attr}]`); + } +}; diff --git a/lib/assets/javascripts/turbograft/click.coffee b/lib/assets/javascripts/turbograft/click.coffee deleted file mode 100644 index ccffe1ac..00000000 --- a/lib/assets/javascripts/turbograft/click.coffee +++ /dev/null @@ -1,34 +0,0 @@ -# The Click class handles clicked links, verifying if Turbolinks should -# take control by inspecting both the event and the link. If it should, -# the page change process is initiated. If not, control is passed back -# to the browser for default functionality. -class window.Click - @installHandlerLast: (event) -> - unless event.defaultPrevented - document.removeEventListener 'click', Click.handle, false - document.addEventListener 'click', Click.handle, false - - @handle: (event) -> - new Click event - - constructor: (@event) -> - return if @event.defaultPrevented - @_extractLink() - if @_validForTurbolinks() - Turbolinks.visit @link.href - @event.preventDefault() - - _extractLink: -> - link = @event.target - link = link.parentNode until !link.parentNode or link.nodeName is 'A' - @link = new Link(link) if link.nodeName is 'A' and link.href.length isnt 0 - - _validForTurbolinks: -> - @link? and not (@link.shouldIgnore() or @_nonStandardClick()) - - _nonStandardClick: -> - @event.which > 1 or - @event.metaKey or - @event.ctrlKey or - @event.shiftKey or - @event.altKey diff --git a/lib/assets/javascripts/turbograft/click.js b/lib/assets/javascripts/turbograft/click.js new file mode 100644 index 00000000..28f1e2ce --- /dev/null +++ b/lib/assets/javascripts/turbograft/click.js @@ -0,0 +1,44 @@ +// The Click class handles clicked links, verifying if Turbolinks should +// take control by inspecting both the event and the link. If it should, +// the page change process is initiated. If not, control is passed back +// to the browser for default functionality. +window.Click = class Click { + static installHandlerLast(event) { + if (!event.defaultPrevented) { + document.removeEventListener('click', Click.handle, false); + document.addEventListener('click', Click.handle, false); + } + } + + static handle(event) { + return new Click(event); + } + + constructor(event) { + this.event = event; + if (this.event.defaultPrevented) { return; } + this._extractLink(); + if (this._validForTurbolinks()) { + Turbolinks.visit(this.link.href); + this.event.preventDefault(); + } + } + + _extractLink() { + let link = this.event.target; + while (!!link.parentNode && (link.nodeName !== 'A')) { link = link.parentNode; } + if ((link.nodeName === 'A') && (link.href.length !== 0)) { this.link = new Link(link); } + } + + _validForTurbolinks() { + return (this.link != null) && !this.link.shouldIgnore() && !this._nonStandardClick(); + } + + _nonStandardClick() { + return (this.event.which > 1) || + this.event.metaKey || + this.event.ctrlKey || + this.event.shiftKey || + this.event.altKey; + } +}; diff --git a/lib/assets/javascripts/turbograft/component_url.coffee b/lib/assets/javascripts/turbograft/component_url.coffee deleted file mode 100644 index 3a0bbf74..00000000 --- a/lib/assets/javascripts/turbograft/component_url.coffee +++ /dev/null @@ -1,24 +0,0 @@ -# The ComponentUrl class converts a basic URL string into an object -# that behaves similarly to document.location. -# -# If an instance is created from a relative URL, the current document -# is used to fill in the missing attributes (protocol, host, port). -class window.ComponentUrl - constructor: (@original = document.location.href) -> - return @original if @original.constructor is ComponentUrl - @_parse() - - withoutHash: -> @href.replace @hash, '' - - # Intention revealing function alias - withoutHashForIE10compatibility: -> @withoutHash() - - hasNoHash: -> @hash.length is 0 - - _parse: -> - (@link ?= document.createElement 'a').href = @original - { @href, @protocol, @host, @hostname, @port, @pathname, @search, @hash } = @link - @origin = [@protocol, '//', @hostname].join '' - @origin += ":#{@port}" unless @port.length is 0 - @relative = [@pathname, @search, @hash].join '' - @absolute = @href diff --git a/lib/assets/javascripts/turbograft/component_url.js b/lib/assets/javascripts/turbograft/component_url.js new file mode 100644 index 00000000..62bb3975 --- /dev/null +++ b/lib/assets/javascripts/turbograft/component_url.js @@ -0,0 +1,53 @@ +/* The ComponentUrl class converts a basic URL string into an object + * that behaves similarly to document.location. + * + * If an instance is created from a relative URL, the current document + * is used to fill in the missing attributes (protocol, host, port). + */ +window.ComponentUrl = class ComponentUrl { + constructor(original, link) { + if (original == null) { + original = document.location.href; + } + if (link == null) { + link = document.createElement('a'); + } + if (original.constructor === ComponentUrl) { + return original; + } + this.original = original; + this.link = link; + this._parse(); + } + + withoutHash() { + return this.href.replace(this.hash, ''); + }; + + // Intention revealing function alias + withoutHashForIE10compatibility() { + return this.withoutHash(); + }; + + hasNoHash() { + return this.hash.length === 0; + }; + + _parse() { + this.link.href = this.original; + this.href = this.link.href; + this.protocol = this.link.protocol; + this.host = this.link.host; + this.hostname = this.link.hostname + this.port = this.link.port; + this.pathname = this.link.pathname; + this.search = this.link.search; + this.hash = this.link.hash; + this.origin = [this.protocol, '//', this.hostname].join(''); + if (this.port.length !== 0) { + this.origin += ":" + this.port; + } + this.relative = [this.pathname, this.search, this.hash].join(''); + return this.absolute = this.href; + }; +}; diff --git a/lib/assets/javascripts/turbograft/csrf_token.coffee b/lib/assets/javascripts/turbograft/csrf_token.coffee deleted file mode 100644 index 52c2bd46..00000000 --- a/lib/assets/javascripts/turbograft/csrf_token.coffee +++ /dev/null @@ -1,9 +0,0 @@ -class window.CSRFToken - @get: (doc = document) -> - node: tag = doc.querySelector 'meta[name="csrf-token"]' - token: tag?.getAttribute? 'content' - - @update: (latest) -> - current = @get() - if current.token? and latest? and current.token isnt latest - current.node.setAttribute 'content', latest diff --git a/lib/assets/javascripts/turbograft/csrf_token.js b/lib/assets/javascripts/turbograft/csrf_token.js new file mode 100644 index 00000000..6321f4b5 --- /dev/null +++ b/lib/assets/javascripts/turbograft/csrf_token.js @@ -0,0 +1,23 @@ +window.CSRFToken = class CSRFToken { + static get(doc) { + if (!doc) { doc = document; } + const tag = doc.querySelector('meta[name="csrf-token"]'); + + const object = { + node: tag + }; + + if (tag) { + object.token = tag.getAttribute('content'); + } + + return object; + } + + static update(latest) { + const current = this.get(); + if ((current.token != null) && (latest != null) && (current.token !== latest)) { + current.node.setAttribute('content', latest); + } + } +}; diff --git a/lib/assets/javascripts/turbograft/document.coffee b/lib/assets/javascripts/turbograft/document.coffee deleted file mode 100644 index 5f70d473..00000000 --- a/lib/assets/javascripts/turbograft/document.coffee +++ /dev/null @@ -1,11 +0,0 @@ -TurboGraft.Document = - create: (html) -> - if /<(html|body)/i.test(html) - doc = document.documentElement.cloneNode() - doc.innerHTML = html - else - doc = document.documentElement.cloneNode(true) - doc.querySelector('body').innerHTML = html - doc.head = doc.querySelector('head') - doc.body = doc.querySelector('body') - doc diff --git a/lib/assets/javascripts/turbograft/document.js b/lib/assets/javascripts/turbograft/document.js new file mode 100644 index 00000000..71108f90 --- /dev/null +++ b/lib/assets/javascripts/turbograft/document.js @@ -0,0 +1,20 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +TurboGraft.Document = { + create(html) { + let doc; + if (/<(html|body)/i.test(html)) { + doc = document.documentElement.cloneNode(); + doc.innerHTML = html; + } else { + doc = document.documentElement.cloneNode(true); + doc.querySelector('body').innerHTML = html; + } + doc.head = doc.querySelector('head'); + doc.body = doc.querySelector('body'); + return doc; + } +}; diff --git a/lib/assets/javascripts/turbograft/initializers.coffee b/lib/assets/javascripts/turbograft/initializers.coffee deleted file mode 100644 index ddefc29c..00000000 --- a/lib/assets/javascripts/turbograft/initializers.coffee +++ /dev/null @@ -1,67 +0,0 @@ -hasClass = (node, search) -> - node.classList.contains(search) - -nodeIsDisabled = (node) -> - node.getAttribute('disabled') || hasClass(node, 'disabled') - -setupRemoteFromTarget = (target, httpRequestType, form = null) -> - httpUrl = target.getAttribute('href') || target.getAttribute('action') - - throw new Error("Turbograft developer error: You did not provide a URL ('#{urlAttribute}' attribute) for data-tg-remote") unless httpUrl - - if TurboGraft.getTGAttribute(target, "remote-once") - TurboGraft.removeTGAttribute(target, "remote-once") - TurboGraft.removeTGAttribute(target, "tg-remote") - - options = - httpRequestType: httpRequestType - httpUrl: httpUrl - fullRefresh: TurboGraft.getTGAttribute(target, 'full-refresh')? - refreshOnSuccess: TurboGraft.getTGAttribute(target, 'refresh-on-success') - refreshOnSuccessExcept: TurboGraft.getTGAttribute(target, 'full-refresh-on-success-except') - refreshOnError: TurboGraft.getTGAttribute(target, 'refresh-on-error') - refreshOnErrorExcept: TurboGraft.getTGAttribute(target, 'full-refresh-on-error-except') - - new TurboGraft.Remote(options, form, target) - -TurboGraft.handlers.remoteMethodHandler = (ev) -> - target = ev.clickTarget - httpRequestType = TurboGraft.getTGAttribute(target, 'tg-remote') - - return unless httpRequestType - ev.preventDefault() - - remote = setupRemoteFromTarget(target, httpRequestType) - remote.submit() - return - -TurboGraft.handlers.remoteFormHandler = (ev) -> - target = ev.target - method = target.getAttribute('method') - - return unless TurboGraft.hasTGAttribute(target, 'tg-remote') - ev.preventDefault() - - remote = setupRemoteFromTarget(target, method, target) - remote.submit() - return - -documentListenerForButtons = (eventType, handler, useCapture = false) -> - document.addEventListener eventType, (ev) -> - target = ev.target - - while target != document && target? - if target.nodeName == "A" || target.nodeName == "BUTTON" - isNodeDisabled = nodeIsDisabled(target) - ev.preventDefault() if isNodeDisabled - unless isNodeDisabled - ev.clickTarget = target - handler(ev) - return - - target = target.parentNode - -documentListenerForButtons('click', TurboGraft.handlers.remoteMethodHandler, true) - -document.addEventListener "submit", (ev) -> - TurboGraft.handlers.remoteFormHandler(ev) diff --git a/lib/assets/javascripts/turbograft/initializers.js b/lib/assets/javascripts/turbograft/initializers.js new file mode 100644 index 00000000..5090d7c4 --- /dev/null +++ b/lib/assets/javascripts/turbograft/initializers.js @@ -0,0 +1,83 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +const hasClass = (node, search) => node.classList.contains(search); + +const nodeIsDisabled = node => node.getAttribute('disabled') || hasClass(node, 'disabled'); + +const setupRemoteFromTarget = function(target, httpRequestType, form = null) { + const httpUrl = target.getAttribute('href') || target.getAttribute('action'); + + if (!httpUrl) { throw new Error(`Turbograft developer error: You did not provide a URL ('${urlAttribute}' attribute) for data-tg-remote`); } + + if (TurboGraft.getTGAttribute(target, "remote-once")) { + TurboGraft.removeTGAttribute(target, "remote-once"); + TurboGraft.removeTGAttribute(target, "tg-remote"); + } + + const options = { + httpRequestType, + httpUrl, + fullRefresh: (TurboGraft.getTGAttribute(target, 'full-refresh') != null), + refreshOnSuccess: TurboGraft.getTGAttribute(target, 'refresh-on-success'), + refreshOnSuccessExcept: TurboGraft.getTGAttribute(target, 'full-refresh-on-success-except'), + refreshOnError: TurboGraft.getTGAttribute(target, 'refresh-on-error'), + refreshOnErrorExcept: TurboGraft.getTGAttribute(target, 'full-refresh-on-error-except') + }; + + return new TurboGraft.Remote(options, form, target); +}; + +TurboGraft.handlers.remoteMethodHandler = function(ev) { + const target = ev.clickTarget; + const httpRequestType = TurboGraft.getTGAttribute(target, 'tg-remote'); + + if (!httpRequestType) { return; } + ev.preventDefault(); + + const remote = setupRemoteFromTarget(target, httpRequestType); + remote.submit(); +}; + +TurboGraft.handlers.remoteFormHandler = function(ev) { + const { + target + } = ev; + const method = target.getAttribute('method'); + + if (!TurboGraft.hasTGAttribute(target, 'tg-remote')) { return; } + ev.preventDefault(); + + const remote = setupRemoteFromTarget(target, method, target); + remote.submit(); +}; + +const documentListenerForButtons = function(eventType, handler, useCapture) { + if (useCapture == null) { useCapture = false; } + return document.addEventListener(eventType, function(ev) { + let { + target + } = ev; + + while ((target !== document) && (target != null)) { + if ((target.nodeName === "A") || (target.nodeName === "BUTTON")) { + var isNodeDisabled = nodeIsDisabled(target); + if (isNodeDisabled) { ev.preventDefault(); } + if (!isNodeDisabled) { + ev.clickTarget = target; + handler(ev); + return; + } + } + + target = target.parentNode; + } + }); +}; + +documentListenerForButtons('click', TurboGraft.handlers.remoteMethodHandler, true); + +document.addEventListener("submit", ev => TurboGraft.handlers.remoteFormHandler(ev)); diff --git a/lib/assets/javascripts/turbograft/link.coffee b/lib/assets/javascripts/turbograft/link.coffee deleted file mode 100644 index dffb2dfa..00000000 --- a/lib/assets/javascripts/turbograft/link.coffee +++ /dev/null @@ -1,41 +0,0 @@ -# The Link class derives from the ComponentUrl class, but is built from an -# existing link element. Provides verification functionality for Turbolinks -# to use in determining whether it should process the link when clicked. -class window.Link extends ComponentUrl - @HTML_EXTENSIONS: ['html'] - - @allowExtensions: (extensions...) -> - Link.HTML_EXTENSIONS.push extension for extension in extensions - Link.HTML_EXTENSIONS - - constructor: (@link) -> - return @link if @link.constructor is Link - @original = @link.href - super - - shouldIgnore: -> - @_crossOrigin() or - @_anchored() or - @_nonHtml() or - @_optOut() or - @_target() - - _crossOrigin: -> - @origin isnt (new ComponentUrl).origin - - _anchored: -> - ((@hash and @withoutHash()) is (current = new ComponentUrl).withoutHash()) or - (@href is current.href + '#') - - _nonHtml: -> - @pathname.match(/\.[a-z]+$/g) and not @pathname.match(new RegExp("\\.(?:#{Link.HTML_EXTENSIONS.join('|')})?$", 'g')) - - _optOut: -> - link = @link - until ignore or link is document or link is null - ignore = link.getAttribute('data-no-turbolink')? - link = link.parentNode - ignore - - _target: -> - @link.target.length isnt 0 diff --git a/lib/assets/javascripts/turbograft/link.js b/lib/assets/javascripts/turbograft/link.js new file mode 100644 index 00000000..bbcc2880 --- /dev/null +++ b/lib/assets/javascripts/turbograft/link.js @@ -0,0 +1,55 @@ +/* The Link class derives from the ComponentUrl class, but is built from an +* existing link element. Provides verification functionality for Turbolinks +* to use in determining whether it should process the link when clicked. +*/ +window.Link = class Link extends ComponentUrl { + HTML_EXTENSIONS = ['html']; + + static allowExtensions() { + var extension, extensions, i, len; + extensions = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + for (i = 0, len = extensions.length; i < len; i++) { + extension = extensions[i]; + Link.HTML_EXTENSIONS.push(extension); + } + return Link.HTML_EXTENSIONS; + }; + + constructor(link) { + if (link.constructor === Link) { + return link; + } + super(link.href, link); + } + + shouldIgnore() { + return this._crossOrigin() || this._anchored() || this._nonHtml() || this._optOut() || this._target(); + }; + + _crossOrigin() { + return this.origin !== (new ComponentUrl).origin; + }; + + _anchored() { + var current; + return ((this.hash && this.withoutHash()) === (current = new ComponentUrl).withoutHash()) || (this.href === current.href + '#'); + }; + + _nonHtml() { + return this.pathname.match(/\.[a-z]+$/g) && !this.pathname.match(new RegExp("\\.(?:" + (Link.HTML_EXTENSIONS.join('|')) + ")?$", 'g')); + }; + + _optOut() { + var ignore, link; + link = this.link; + while (!(ignore || link === document || link === null)) { + ignore = link.getAttribute('data-no-turbolink') != null; + link = link.parentNode; + } + return ignore; + }; + + _target() { + return this.link.target.length !== 0; + }; +}; diff --git a/lib/assets/javascripts/turbograft/page.coffee b/lib/assets/javascripts/turbograft/page.coffee deleted file mode 100644 index 949f532d..00000000 --- a/lib/assets/javascripts/turbograft/page.coffee +++ /dev/null @@ -1,77 +0,0 @@ -window.Page = {} if !window.Page - -Page.visit = (url, opts={}) -> - if opts.reload - window.location = url - else - Turbolinks.visit(url) - -Page.refresh = (options = {}, callback) -> - newUrl = if options.url - options.url - else if options.queryParams - paramString = $.param(options.queryParams) - paramString = "?#{paramString}" if paramString - location.pathname + paramString - else - location.href - - turboOptions = { - partialReplace: true, - exceptKeys: options.exceptKeys, - onlyKeys: options.onlyKeys, - updatePushState: options.updatePushState, - callback: callback - } - - if xhr = options.response - Turbolinks.loadPage null, xhr, turboOptions - else - Turbolinks.visit newUrl, turboOptions - -Page.open = -> - window.open(arguments...) - -# Providing hooks for objects to set up destructors: -onReplaceCallbacks = [] - -# e.g., Page.onReplace(node, unbindListenersFnc) -# unbindListenersFnc will be called if the node in question is partially replaced -# or if a full replace occurs. It will be called only once -Page.onReplace = (node, callback) -> - throw new Error("Page.onReplace: Node and callback must both be specified") if !node || !callback - throw new Error("Page.onReplace: Callback must be a function") if !isFunction(callback) - onReplaceCallbacks.push({node, callback}) - -# option C from http://jsperf.com/alternative-isfunction-implementations -isFunction = (object) -> - !!(object && object.constructor && object.call && object.apply) - -# roughly based on http://davidwalsh.name/check-parent-node (note, OP is incorrect) -contains = (parentNode, childNode) -> - if parentNode.contains - parentNode.contains(childNode) - else # old browser compatability - !!((parentNode == childNode) || (parentNode.compareDocumentPosition(childNode) & Node.DOCUMENT_POSITION_CONTAINED_BY)) - -document.addEventListener 'page:before-partial-replace', (event) -> - replacedNodes = event.data - - unprocessedOnReplaceCallbacks = [] - for entry in onReplaceCallbacks - fired = false - for replacedNode in replacedNodes - if contains(replacedNode, entry.node) - entry.callback() - fired = true - break - - unless fired - unprocessedOnReplaceCallbacks.push(entry) - - onReplaceCallbacks = unprocessedOnReplaceCallbacks - -document.addEventListener 'page:before-replace', (event) -> - for entry in onReplaceCallbacks - entry.callback() - onReplaceCallbacks = [] diff --git a/lib/assets/javascripts/turbograft/page.js b/lib/assets/javascripts/turbograft/page.js new file mode 100644 index 00000000..b774010c --- /dev/null +++ b/lib/assets/javascripts/turbograft/page.js @@ -0,0 +1,105 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +if (!window.Page) { window.Page = {}; } + +Page.visit = function(url, opts) { + if (opts == null) { opts = {}; } + if (opts.reload) { + return window.location = url; + } else { + return Turbolinks.visit(url); + } +}; + +Page.refresh = function(options, callback) { + let xhr; + if (options == null) { options = {}; } + const newUrl = (() => { + if (options.url) { + return options.url; + } else if (options.queryParams) { + let paramString = $.param(options.queryParams); + if (paramString) { paramString = `?${paramString}`; } + return location.pathname + paramString; + } else { + return location.href; + } + })(); + + const turboOptions = { + partialReplace: true, + exceptKeys: options.exceptKeys, + onlyKeys: options.onlyKeys, + updatePushState: options.updatePushState, + callback + }; + + if ((xhr = options.response)) { + return Turbolinks.loadPage(null, xhr, turboOptions); + } else { + return Turbolinks.visit(newUrl, turboOptions); + } +}; + +Page.open = function() { + return window.open(...arguments); +}; + +// Providing hooks for objects to set up destructors: +let onReplaceCallbacks = []; + +// e.g., Page.onReplace(node, unbindListenersFnc) +// unbindListenersFnc will be called if the node in question is partially replaced +// or if a full replace occurs. It will be called only once +Page.onReplace = function(node, callback) { + if (!node || !callback) { throw new Error("Page.onReplace: Node and callback must both be specified"); } + if (!isFunction(callback)) { throw new Error("Page.onReplace: Callback must be a function"); } + return onReplaceCallbacks.push({node, callback}); +}; + +// option C from http://jsperf.com/alternative-isfunction-implementations +var isFunction = object => !!(object && object.constructor && object.call && object.apply); + +// roughly based on http://davidwalsh.name/check-parent-node (note, OP is incorrect) +const contains = function(parentNode, childNode) { + if (parentNode.contains) { + return parentNode.contains(childNode); + } else { // old browser compatability + return !!((parentNode === childNode) || (parentNode.compareDocumentPosition(childNode) & Node.DOCUMENT_POSITION_CONTAINED_BY)); + } +}; + +document.addEventListener('page:before-partial-replace', function(event) { + const replacedNodes = event.data; + + const unprocessedOnReplaceCallbacks = []; + for (var entry of Array.from(onReplaceCallbacks)) { + var fired = false; + for (var replacedNode of Array.from(replacedNodes)) { + if (contains(replacedNode, entry.node)) { + entry.callback(); + fired = true; + break; + } + } + + if (!fired) { + unprocessedOnReplaceCallbacks.push(entry); + } + } + + return onReplaceCallbacks = unprocessedOnReplaceCallbacks; +}); + +document.addEventListener('page:before-replace', function(event) { + for (var entry of Array.from(onReplaceCallbacks)) { + entry.callback(); + } + return onReplaceCallbacks = []; +}); diff --git a/lib/assets/javascripts/turbograft/remote.coffee b/lib/assets/javascripts/turbograft/remote.coffee deleted file mode 100644 index cd58589f..00000000 --- a/lib/assets/javascripts/turbograft/remote.coffee +++ /dev/null @@ -1,179 +0,0 @@ -class TurboGraft.Remote - constructor: (@opts, form, target) -> - - @initiator = form || target - - @actualRequestType = if @opts.httpRequestType?.toLowerCase() == 'get' then 'GET' else 'POST' - @useNativeEncoding = @opts.useNativeEncoding - - @formData = @createPayload(form) - - @refreshOnSuccess = @opts.refreshOnSuccess.split(" ") if @opts.refreshOnSuccess - @refreshOnSuccessExcept = @opts.refreshOnSuccessExcept.split(" ") if @opts.refreshOnSuccessExcept - @refreshOnError = @opts.refreshOnError.split(" ") if @opts.refreshOnError - @refreshOnErrorExcept = @opts.refreshOnErrorExcept.split(" ") if @opts.refreshOnErrorExcept - - xhr = new XMLHttpRequest - if @actualRequestType == 'GET' - url = if @formData then @opts.httpUrl + "?#{@formData}" else @opts.httpUrl - xhr.open(@actualRequestType, url, true) - else - xhr.open(@actualRequestType, @opts.httpUrl, true) - xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') - xhr.setRequestHeader('Accept', 'text/html, application/xhtml+xml, application/xml') - xhr.setRequestHeader("Content-Type", @contentType) if @contentType - xhr.setRequestHeader 'X-XHR-Referer', document.location.href - - csrfToken = CSRFToken.get().token - xhr.setRequestHeader('X-CSRF-Token', csrfToken) if csrfToken - - triggerEventFor('turbograft:remote:init', @initiator, {xhr: xhr, initiator: @initiator}) - - xhr.addEventListener 'loadstart', => - triggerEventFor 'turbograft:remote:start', @initiator, - xhr: xhr - - xhr.addEventListener 'error', @onError - xhr.addEventListener 'load', (event) => - if xhr.status < 400 - @onSuccess(event) - else - @onError(event) - - xhr.addEventListener 'loadend', => - @opts.done?() - triggerEventFor 'turbograft:remote:always', @initiator, - initiator: @initiator - xhr: xhr - - @xhr = xhr - - submit: -> - @xhr.send(@formData) - - createPayload: (form) -> - if form - if @useNativeEncoding || form.querySelectorAll("[type='file'][name]").length > 0 - formData = @nativeEncodeForm(form) - else # for much smaller payloads - formData = @uriEncodeForm(form) - else - formData = '' - - if formData not instanceof FormData - @contentType = "application/x-www-form-urlencoded; charset=UTF-8" - formData = @formAppend(formData, "_method", @opts.httpRequestType) if formData.indexOf("_method") == -1 && @opts.httpRequestType && @actualRequestType != 'GET' - - formData - - formAppend: (uriEncoded, key, value) -> - uriEncoded += "&" if uriEncoded.length - uriEncoded += "#{encodeURIComponent(key)}=#{encodeURIComponent(value)}" - - uriEncodeForm: (form) -> - formData = "" - @_iterateOverFormInputs form, (input) => - formData = @formAppend(formData, input.name, input.value) - formData - - formDataAppend: (formData, input) -> - if input.type == 'file' - for file in input.files - formData.append(input.name, file) - else - formData.append(input.name, input.value) - formData - - nativeEncodeForm: (form) -> - formData = new FormData - @_iterateOverFormInputs form, (input) => - formData = @formDataAppend(formData, input) - formData - - _iterateOverFormInputs: (form, callback) -> - inputs = @_enabledInputs(form) - for input in inputs - inputEnabled = !input.disabled - radioOrCheck = (input.type == 'checkbox' || input.type == 'radio') - - if inputEnabled && input.name - if (radioOrCheck && input.checked) || !radioOrCheck - callback(input) - - _enabledInputs: (form) -> - selector = "input:not([type='reset']):not([type='button']):not([type='submit']):not([type='image']), select, textarea" - inputs = Array::slice.call(form.querySelectorAll(selector)) - disabledNodes = Array::slice.call(TurboGraft.querySelectorAllTGAttribute(form, 'tg-remote-noserialize')) - - return inputs unless disabledNodes.length - - disabledInputs = disabledNodes - for node in disabledNodes - disabledInputs = disabledInputs.concat(Array::slice.call(node.querySelectorAll(selector))) - - enabledInputs = [] - for input in inputs when disabledInputs.indexOf(input) < 0 - enabledInputs.push(input) - enabledInputs - - onSuccess: (ev) => - @opts.success?() - - xhr = ev.target - triggerEventFor 'turbograft:remote:success', @initiator, - initiator: @initiator - xhr: xhr - - if redirect = xhr.getResponseHeader('X-Next-Redirect') - Page.visit(redirect, reload: true) - return - - unless TurboGraft.hasTGAttribute(@initiator, 'tg-remote-norefresh') - if @opts.fullRefresh && @refreshOnSuccess - Page.refresh(onlyKeys: @refreshOnSuccess) - else if @opts.fullRefresh - Page.refresh() - else if @refreshOnSuccess - Page.refresh( - response: xhr - onlyKeys: @refreshOnSuccess - ) - else if @refreshOnSuccessExcept - Page.refresh( - response: xhr - exceptKeys: @refreshOnSuccessExcept - ) - else - Page.refresh( - response: xhr - ) - - onError: (ev) => - @opts.fail?() - - xhr = ev.target - triggerEventFor 'turbograft:remote:fail', @initiator, - initiator: @initiator - xhr: xhr - - if TurboGraft.hasTGAttribute(@initiator, 'tg-remote-norefresh') - triggerEventFor 'turbograft:remote:fail:unhandled', @initiator, - xhr: xhr - else - if @opts.fullRefresh && @refreshOnError - Page.refresh(onlyKeys: @refreshOnError) - else if @opts.fullRefresh - Page.refresh() - else if @refreshOnError - Page.refresh( - response: xhr - onlyKeys: @refreshOnError - ) - else if @refreshOnErrorExcept - Page.refresh( - response: xhr - exceptKeys: @refreshOnErrorExcept - ) - else - triggerEventFor 'turbograft:remote:fail:unhandled', @initiator, - xhr: xhr diff --git a/lib/assets/javascripts/turbograft/remote.js b/lib/assets/javascripts/turbograft/remote.js new file mode 100644 index 00000000..44687b9e --- /dev/null +++ b/lib/assets/javascripts/turbograft/remote.js @@ -0,0 +1,248 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS205: Consider reworking code to avoid use of IIFEs + * DS207: Consider shorter variations of null checks + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +TurboGraft.Remote = class Remote { + constructor(opts, form, target) { + + this.onSuccess = this.onSuccess.bind(this); + this.onError = this.onError.bind(this); + this.opts = opts; + this.initiator = form || target; + + this.actualRequestType = (this.opts.httpRequestType != null ? this.opts.httpRequestType.toLowerCase() : undefined) === 'get' ? 'GET' : 'POST'; + this.useNativeEncoding = this.opts.useNativeEncoding; + + this.formData = this.createPayload(form); + + if (this.opts.refreshOnSuccess) { this.refreshOnSuccess = this.opts.refreshOnSuccess.split(" "); } + if (this.opts.refreshOnSuccessExcept) { this.refreshOnSuccessExcept = this.opts.refreshOnSuccessExcept.split(" "); } + if (this.opts.refreshOnError) { this.refreshOnError = this.opts.refreshOnError.split(" "); } + if (this.opts.refreshOnErrorExcept) { this.refreshOnErrorExcept = this.opts.refreshOnErrorExcept.split(" "); } + + const xhr = new XMLHttpRequest; + if (this.actualRequestType === 'GET') { + const url = this.formData ? this.opts.httpUrl + `?${this.formData}` : this.opts.httpUrl; + xhr.open(this.actualRequestType, url, true); + } else { + xhr.open(this.actualRequestType, this.opts.httpUrl, true); + } + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.setRequestHeader('Accept', 'text/html, application/xhtml+xml, application/xml'); + if (this.contentType) { xhr.setRequestHeader("Content-Type", this.contentType); } + xhr.setRequestHeader('X-XHR-Referer', document.location.href); + + const csrfToken = CSRFToken.get().token; + if (csrfToken) { xhr.setRequestHeader('X-CSRF-Token', csrfToken); } + + triggerEventFor('turbograft:remote:init', this.initiator, {xhr, initiator: this.initiator}); + + xhr.addEventListener('loadstart', () => { + return triggerEventFor('turbograft:remote:start', this.initiator, + {xhr}); + }); + + xhr.addEventListener('error', this.onError); + xhr.addEventListener('load', event => { + if (xhr.status < 400) { + return this.onSuccess(event); + } else { + return this.onError(event); + } + }); + + xhr.addEventListener('loadend', () => { + if (typeof this.opts.done === 'function') { + this.opts.done(); + } + return triggerEventFor('turbograft:remote:always', this.initiator, { + initiator: this.initiator, + xhr + } + ); + }); + + this.xhr = xhr; + } + + submit() { + return this.xhr.send(this.formData); + } + + createPayload(form) { + let formData; + if (form) { + if (this.useNativeEncoding || (form.querySelectorAll("[type='file'][name]").length > 0)) { + formData = this.nativeEncodeForm(form); + } else { // for much smaller payloads + formData = this.uriEncodeForm(form); + } + } else { + formData = ''; + } + + if (!(formData instanceof FormData)) { + this.contentType = "application/x-www-form-urlencoded; charset=UTF-8"; + if ((formData.indexOf("_method") === -1) && this.opts.httpRequestType && (this.actualRequestType !== 'GET')) { formData = this.formAppend(formData, "_method", this.opts.httpRequestType); } + } + + return formData; + } + + formAppend(uriEncoded, key, value) { + if (uriEncoded.length) { uriEncoded += "&"; } + return uriEncoded += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } + + uriEncodeForm(form) { + let formData = ""; + this._iterateOverFormInputs(form, input => { + return formData = this.formAppend(formData, input.name, input.value); + }); + return formData; + } + + formDataAppend(formData, input) { + if (input.type === 'file') { + for (var file of Array.from(input.files)) { + formData.append(input.name, file); + } + } else { + formData.append(input.name, input.value); + } + return formData; + } + + nativeEncodeForm(form) { + let formData = new FormData; + this._iterateOverFormInputs(form, input => { + return formData = this.formDataAppend(formData, input); + }); + return formData; + } + + _iterateOverFormInputs(form, callback) { + const inputs = this._enabledInputs(form); + return (() => { + const result = []; + for (var input of Array.from(inputs)) { + var inputEnabled = !input.disabled; + var radioOrCheck = ((input.type === 'checkbox') || (input.type === 'radio')); + + if (inputEnabled && input.name) { + if ((radioOrCheck && input.checked) || !radioOrCheck) { + result.push(callback(input)); + } else { + result.push(undefined); + } + } else { + result.push(undefined); + } + } + return result; + })(); + } + + _enabledInputs(form) { + const selector = "input:not([type='reset']):not([type='button']):not([type='submit']):not([type='image']), select, textarea"; + const inputs = Array.prototype.slice.call(form.querySelectorAll(selector)); + const disabledNodes = Array.prototype.slice.call(TurboGraft.querySelectorAllTGAttribute(form, 'tg-remote-noserialize')); + + if (!disabledNodes.length) { return inputs; } + + let disabledInputs = disabledNodes; + for (var node of Array.from(disabledNodes)) { + disabledInputs = disabledInputs.concat(Array.prototype.slice.call(node.querySelectorAll(selector))); + } + + const enabledInputs = []; + for (var input of Array.from(inputs)) { + if (disabledInputs.indexOf(input) < 0) { + enabledInputs.push(input); + } + } + return enabledInputs; + } + + onSuccess(ev) { + let redirect; + if (typeof this.opts.success === 'function') { + this.opts.success(); + } + + const xhr = ev.target; + triggerEventFor('turbograft:remote:success', this.initiator, { + initiator: this.initiator, + xhr + } + ); + + if (redirect = xhr.getResponseHeader('X-Next-Redirect')) { + Page.visit(redirect, {reload: true}); + return; + } + + if (!TurboGraft.hasTGAttribute(this.initiator, 'tg-remote-norefresh')) { + if (this.opts.fullRefresh && this.refreshOnSuccess) { + return Page.refresh({onlyKeys: this.refreshOnSuccess}); + } else if (this.opts.fullRefresh) { + return Page.refresh(); + } else if (this.refreshOnSuccess) { + return Page.refresh({ + response: xhr, + onlyKeys: this.refreshOnSuccess + }); + } else if (this.refreshOnSuccessExcept) { + return Page.refresh({ + response: xhr, + exceptKeys: this.refreshOnSuccessExcept + }); + } else { + return Page.refresh({ + response: xhr + }); + } + } + } + + onError(ev) { + if (typeof this.opts.fail === 'function') { + this.opts.fail(); + } + + const xhr = ev.target; + triggerEventFor('turbograft:remote:fail', this.initiator, { + initiator: this.initiator, + xhr + } + ); + + if (TurboGraft.hasTGAttribute(this.initiator, 'tg-remote-norefresh')) { + return triggerEventFor('turbograft:remote:fail:unhandled', this.initiator, + {xhr}); + } else { + if (this.opts.fullRefresh && this.refreshOnError) { + return Page.refresh({onlyKeys: this.refreshOnError}); + } else if (this.opts.fullRefresh) { + return Page.refresh(); + } else if (this.refreshOnError) { + return Page.refresh({ + response: xhr, + onlyKeys: this.refreshOnError + }); + } else if (this.refreshOnErrorExcept) { + return Page.refresh({ + response: xhr, + exceptKeys: this.refreshOnErrorExcept + }); + } else { + return triggerEventFor('turbograft:remote:fail:unhandled', this.initiator, + {xhr}); + } + } + } +}; diff --git a/lib/assets/javascripts/turbograft/response.coffee b/lib/assets/javascripts/turbograft/response.coffee deleted file mode 100644 index cf3437d4..00000000 --- a/lib/assets/javascripts/turbograft/response.coffee +++ /dev/null @@ -1,31 +0,0 @@ -class TurboGraft.Response - constructor: (@xhr, intendedURL) -> - if intendedURL && intendedURL.withoutHash() != @xhr.responseURL - redirectedTo = @xhr.responseURL - else - redirectedTo = @xhr.getResponseHeader('X-XHR-Redirected-To') - - @finalURL = redirectedTo || intendedURL - - valid: -> @hasRenderableHttpStatus() && @hasValidContent() - - document: -> - if @valid() - TurboGraft.Document.create(@xhr.responseText) - - hasRenderableHttpStatus: -> - return true if @xhr.status == 422 # we want to render form validations - !(400 <= @xhr.status < 600) - - hasValidContent: -> - if contentType = @xhr.getResponseHeader('Content-Type') - contentType.match(/^(?:text\/html|application\/xhtml\+xml|application\/xml)(?:;|$)/) - else - throw new Error("Error encountered for XHR Response: #{this}") - - toString: () -> - "URL: #{@xhr.responseURL}, " + - "ReadyState: #{@xhr.readyState}, " + - "Headers: #{@xhr.getAllResponseHeaders()}" - -TurboGraft.location = () -> location.href diff --git a/lib/assets/javascripts/turbograft/response.js b/lib/assets/javascripts/turbograft/response.js new file mode 100644 index 00000000..bac870fd --- /dev/null +++ b/lib/assets/javascripts/turbograft/response.js @@ -0,0 +1,48 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +TurboGraft.Response = class Response { + constructor(xhr, intendedURL) { + let redirectedTo; + this.xhr = xhr; + if (intendedURL && (intendedURL.withoutHash() !== this.xhr.responseURL)) { + redirectedTo = this.xhr.responseURL; + } else { + redirectedTo = this.xhr.getResponseHeader('X-XHR-Redirected-To'); + } + + this.finalURL = redirectedTo || intendedURL; + } + + valid() { return this.hasRenderableHttpStatus() && this.hasValidContent(); } + + document() { + if (this.valid()) { + return TurboGraft.Document.create(this.xhr.responseText); + } + } + + hasRenderableHttpStatus() { + if (this.xhr.status === 422) { return true; } // we want to render form validations + return !(400 <= this.xhr.status && this.xhr.status < 600); + } + + hasValidContent() { + let contentType; + if (contentType = this.xhr.getResponseHeader('Content-Type')) { + return contentType.match(/^(?:text\/html|application\/xhtml\+xml|application\/xml)(?:;|$)/); + } else { + throw new Error(`Error encountered for XHR Response: ${this}`); + } + } + + toString() { + return `URL: ${this.xhr.responseURL}, ` + + `ReadyState: ${this.xhr.readyState}, ` + + `Headers: ${this.xhr.getAllResponseHeaders()}`; + } +}; + +TurboGraft.location = () => location.href; diff --git a/lib/assets/javascripts/turbograft/turbohead.coffee b/lib/assets/javascripts/turbograft/turbohead.coffee deleted file mode 100644 index e6bcc203..00000000 --- a/lib/assets/javascripts/turbograft/turbohead.coffee +++ /dev/null @@ -1,142 +0,0 @@ -TRACKED_ASSET_SELECTOR = '[data-turbolinks-track]' -TRACKED_ATTRIBUTE_NAME = 'turbolinksTrack' -ANONYMOUS_TRACK_VALUE = 'true' - -scriptPromises = {} -resolvePreviousRequest = null - -waitForCompleteDownloads = -> - loadingPromises = Object.keys(scriptPromises).map (url) -> - scriptPromises[url] - Promise.all(loadingPromises) - -class TurboGraft.TurboHead - constructor: (@activeDocument, @upstreamDocument) -> - @activeAssets = extractTrackedAssets(@activeDocument) - @upstreamAssets = extractTrackedAssets(@upstreamDocument) - @newScripts = @upstreamAssets - .filter(attributeMatches('nodeName', 'SCRIPT')) - .filter(noAttributeMatchesIn('src', @activeAssets)) - - @newLinks = @upstreamAssets - .filter(attributeMatches('nodeName', 'LINK')) - .filter(noAttributeMatchesIn('href', @activeAssets)) - - @_testAPI: { - reset: -> - scriptPromises = {} - resolvePreviousRequest = null - } - - hasChangedAnonymousAssets: () -> - anonymousUpstreamAssets = @upstreamAssets - .filter(datasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE)) - anonymousActiveAssets = @activeAssets - .filter(datasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE)) - - if anonymousActiveAssets.length != anonymousUpstreamAssets.length - return true - - noMatchingSrc = noAttributeMatchesIn('src', anonymousUpstreamAssets) - noMatchingHref = noAttributeMatchesIn('href', anonymousUpstreamAssets) - - anonymousActiveAssets.some((node) -> - noMatchingSrc(node) || noMatchingHref(node) - ) - - movingFromTrackedToUntracked: () -> - @upstreamAssets.length == 0 && @activeAssets.length > 0 - - hasNamedAssetConflicts: () -> - @newScripts - .concat(@newLinks) - .filter(noDatasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE)) - .some(datasetMatchesIn(TRACKED_ATTRIBUTE_NAME, @activeAssets)) - - hasAssetConflicts: () -> - @movingFromTrackedToUntracked() || - @hasNamedAssetConflicts() || - @hasChangedAnonymousAssets() - - waitForAssets: () -> - resolvePreviousRequest?(isCanceled: true) - - new Promise((resolve) => - resolvePreviousRequest = resolve - waitForCompleteDownloads() - .then(@_insertNewAssets) - .then(waitForCompleteDownloads) - .then(resolve) - ) - - _insertNewAssets: () => - updateLinkTags(@activeDocument, @newLinks) - updateScriptTags(@activeDocument, @newScripts) - -extractTrackedAssets = (doc) -> - [].slice.call(doc.querySelectorAll(TRACKED_ASSET_SELECTOR)) - -attributeMatches = (attribute, value) -> - (node) -> node[attribute] == value - -attributeMatchesIn = (attribute, collection) -> - (node) -> - collection.some((nodeFromCollection) -> node[attribute] == nodeFromCollection[attribute]) - -noAttributeMatchesIn = (attribute, collection) -> - (node) -> - !collection.some((nodeFromCollection) -> node[attribute] == nodeFromCollection[attribute]) - -datasetMatches = (attribute, value) -> - (node) -> node.dataset[attribute] == value - -noDatasetMatches = (attribute, value) -> - (node) -> node.dataset[attribute] != value - -datasetMatchesIn = (attribute, collection) -> - (node) -> - value = node.dataset[attribute] - collection.some(datasetMatches(attribute, value)) - -noDatasetMatchesIn = (attribute, collection) -> - (node) -> - value = node.dataset[attribute] - !collection.some(datasetMatches(attribute, value)) - -updateLinkTags = (activeDocument, newLinks) -> - # style tag load events don't work in all browsers - # as such we just hope they load ¯\_(ツ)_/¯ - newLinks.forEach((linkNode) -> - newNode = linkNode.cloneNode() - activeDocument.head.appendChild(newNode) - triggerEvent("page:after-link-inserted", newNode) - ) - -updateScriptTags = (activeDocument, newScripts) -> - promise = Promise.resolve() - newScripts.forEach (scriptNode) -> - promise = promise.then(-> insertScript(activeDocument, scriptNode)) - promise - -insertScript = (activeDocument, scriptNode) -> - url = scriptNode.src - if scriptPromises[url] - return scriptPromises[url] - - # Clone script tags to guarantee browser execution. - newNode = activeDocument.createElement('SCRIPT') - newNode.setAttribute(attr.name, attr.value) for attr in scriptNode.attributes - newNode.appendChild(activeDocument.createTextNode(scriptNode.innerHTML)) - - scriptPromises[url] = new Promise((resolve) -> - onAssetEvent = (event) -> - triggerEvent("page:#script-error", event) if event.type == 'error' - newNode.removeEventListener('load', onAssetEvent) - newNode.removeEventListener('error', onAssetEvent) - resolve() - - newNode.addEventListener('load', onAssetEvent) - newNode.addEventListener('error', onAssetEvent) - activeDocument.head.appendChild(newNode) - triggerEvent("page:after-script-inserted", newNode) - ) diff --git a/lib/assets/javascripts/turbograft/turbohead.js b/lib/assets/javascripts/turbograft/turbohead.js new file mode 100644 index 00000000..1dfe6de7 --- /dev/null +++ b/lib/assets/javascripts/turbograft/turbohead.js @@ -0,0 +1,159 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * DS206: Consider reworking classes to avoid initClass + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +const TRACKED_ASSET_SELECTOR = '[data-turbolinks-track]'; +const TRACKED_ATTRIBUTE_NAME = 'turbolinksTrack'; +const ANONYMOUS_TRACK_VALUE = 'true'; + +let scriptPromises = {}; +let resolvePreviousRequest = null; + +const waitForCompleteDownloads = function() { + const loadingPromises = Object.keys(scriptPromises).map(url => scriptPromises[url]); + return Promise.all(loadingPromises); +}; + +const Cls = (TurboGraft.TurboHead = class TurboHead { + static initClass() { + + this._testAPI = { + reset() { + scriptPromises = {}; + return resolvePreviousRequest = null; + } + }; + } + constructor(activeDocument, upstreamDocument) { + this._insertNewAssets = this._insertNewAssets.bind(this); + this.activeDocument = activeDocument; + this.upstreamDocument = upstreamDocument; + this.activeAssets = extractTrackedAssets(this.activeDocument); + this.upstreamAssets = extractTrackedAssets(this.upstreamDocument); + this.newScripts = this.upstreamAssets + .filter(attributeMatches('nodeName', 'SCRIPT')) + .filter(noAttributeMatchesIn('src', this.activeAssets)); + + this.newLinks = this.upstreamAssets + .filter(attributeMatches('nodeName', 'LINK')) + .filter(noAttributeMatchesIn('href', this.activeAssets)); + } + + hasChangedAnonymousAssets() { + const anonymousUpstreamAssets = this.upstreamAssets + .filter(datasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE)); + const anonymousActiveAssets = this.activeAssets + .filter(datasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE)); + + if (anonymousActiveAssets.length !== anonymousUpstreamAssets.length) { + return true; + } + + const noMatchingSrc = noAttributeMatchesIn('src', anonymousUpstreamAssets); + const noMatchingHref = noAttributeMatchesIn('href', anonymousUpstreamAssets); + + return anonymousActiveAssets.some(node => noMatchingSrc(node) || noMatchingHref(node)); + } + + movingFromTrackedToUntracked() { + return (this.upstreamAssets.length === 0) && (this.activeAssets.length > 0); + } + + hasNamedAssetConflicts() { + return this.newScripts + .concat(this.newLinks) + .filter(noDatasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE)) + .some(datasetMatchesIn(TRACKED_ATTRIBUTE_NAME, this.activeAssets)); + } + + hasAssetConflicts() { + return this.movingFromTrackedToUntracked() || + this.hasNamedAssetConflicts() || + this.hasChangedAnonymousAssets(); + } + + waitForAssets() { + if (typeof resolvePreviousRequest === 'function') { + resolvePreviousRequest({isCanceled: true}); + } + + return new Promise(resolve => { + resolvePreviousRequest = resolve; + return waitForCompleteDownloads() + .then(this._insertNewAssets) + .then(waitForCompleteDownloads) + .then(resolve); + }); + } + + _insertNewAssets() { + updateLinkTags(this.activeDocument, this.newLinks); + return updateScriptTags(this.activeDocument, this.newScripts); + } +}); +Cls.initClass(); + +var extractTrackedAssets = doc => [].slice.call(doc.querySelectorAll(TRACKED_ASSET_SELECTOR)); + +var attributeMatches = (attribute, value) => node => node[attribute] === value; + +const attributeMatchesIn = (attribute, collection) => node => collection.some(nodeFromCollection => node[attribute] === nodeFromCollection[attribute]); + +var noAttributeMatchesIn = (attribute, collection) => node => !collection.some(nodeFromCollection => node[attribute] === nodeFromCollection[attribute]); + +var datasetMatches = (attribute, value) => node => node.dataset[attribute] === value; + +var noDatasetMatches = (attribute, value) => node => node.dataset[attribute] !== value; + +var datasetMatchesIn = (attribute, collection) => (function(node) { + const value = node.dataset[attribute]; + return collection.some(datasetMatches(attribute, value)); +}); + +const noDatasetMatchesIn = (attribute, collection) => (function(node) { + const value = node.dataset[attribute]; + return !collection.some(datasetMatches(attribute, value)); +}); + +var updateLinkTags = (activeDocument, newLinks) => // style tag load events don't work in all browsers +// as such we just hope they load ¯\_(ツ)_/¯ +newLinks.forEach(function(linkNode) { + const newNode = linkNode.cloneNode(); + activeDocument.head.appendChild(newNode); + return triggerEvent("page:after-link-inserted", newNode); +}); + +var updateScriptTags = function(activeDocument, newScripts) { + let promise = Promise.resolve(); + newScripts.forEach(scriptNode => promise = promise.then(() => insertScript(activeDocument, scriptNode))); + return promise; +}; + +var insertScript = function(activeDocument, scriptNode) { + const url = scriptNode.src; + if (scriptPromises[url]) { + return scriptPromises[url]; + } + + // Clone script tags to guarantee browser execution. + const newNode = activeDocument.createElement('SCRIPT'); + for (var attr of Array.from(scriptNode.attributes)) { newNode.setAttribute(attr.name, attr.value); } + newNode.appendChild(activeDocument.createTextNode(scriptNode.innerHTML)); + + return scriptPromises[url] = new Promise(function(resolve) { + var onAssetEvent = function(event) { + if (event.type === 'error') { triggerEvent("page:#script-error", event); } + newNode.removeEventListener('load', onAssetEvent); + newNode.removeEventListener('error', onAssetEvent); + return resolve(); + }; + + newNode.addEventListener('load', onAssetEvent); + newNode.addEventListener('error', onAssetEvent); + activeDocument.head.appendChild(newNode); + return triggerEvent("page:after-script-inserted", newNode); + }); +}; diff --git a/lib/assets/javascripts/turbograft/turbolinks.coffee b/lib/assets/javascripts/turbograft/turbolinks.coffee deleted file mode 100644 index 022e8958..00000000 --- a/lib/assets/javascripts/turbograft/turbolinks.coffee +++ /dev/null @@ -1,361 +0,0 @@ -Response = TurboGraft.Response -TurboHead = TurboGraft.TurboHead -jQuery = window.jQuery - -xhr = null -activeDocument = document - -installDocumentReadyPageEventTriggers = -> - activeDocument.addEventListener 'DOMContentLoaded', ( -> - triggerEvent 'page:change' - triggerEvent 'page:update' - ), true - -installJqueryAjaxSuccessPageUpdateTrigger = -> - if typeof jQuery isnt 'undefined' - jQuery(activeDocument).on 'ajaxSuccess', (event, xhr, settings) -> - return unless jQuery.trim xhr.responseText - triggerEvent 'page:update' - -# Handle bug in Firefox 26/27 where history.state is initially undefined -historyStateIsDefined = - window.history.state != undefined or navigator.userAgent.match /Firefox\/2[6|7]/ - -browserSupportsPushState = - window.history and window.history.pushState and window.history.replaceState and historyStateIsDefined - -window.triggerEvent = (name, data) -> - event = activeDocument.createEvent 'Events' - event.data = data if data - event.initEvent name, true, true - activeDocument.dispatchEvent event - -window.triggerEventFor = (name, node, data) -> - event = activeDocument.createEvent 'Events' - event.data = data if data - event.initEvent name, true, true - node.dispatchEvent event - -popCookie = (name) -> - value = activeDocument.cookie.match(new RegExp(name+"=(\\w+)"))?[1].toUpperCase() or '' - activeDocument.cookie = name + '=; expires=Thu, 01-Jan-70 00:00:01 GMT; path=/' - value - -requestMethodIsSafe = - popCookie('request_method') in ['GET',''] - -browserSupportsTurbolinks = browserSupportsPushState and requestMethodIsSafe - -browserSupportsCustomEvents = - activeDocument.addEventListener and activeDocument.createEvent - -if browserSupportsCustomEvents - installDocumentReadyPageEventTriggers() - installJqueryAjaxSuccessPageUpdateTrigger() - -replaceNode = (newNode, oldNode) -> - replacedNode = oldNode.parentNode.replaceChild(newNode, oldNode) - triggerEvent('page:after-node-removed', replacedNode) - -removeNode = (node) -> - removedNode = node.parentNode.removeChild(node) - triggerEvent('page:after-node-removed', removedNode) - -# TODO: triggerEvent should be accessible to all these guys -# on some kind of eventbus -# TODO: clean up everything above me ^ -# TODO: decide on the public API -class window.Turbolinks - currentState = null - referer = null - - fetch = (url, options = {}) -> - return if pageChangePrevented(url) - url = new ComponentUrl(url) - - rememberReferer() - - fetchReplacement(url, options) - - isPartialReplace = (response, options) -> - Boolean( - options.partialReplace || - options.onlyKeys?.length || - options.exceptKeys?.length - ) - - @fullPageNavigate: (url) -> - if url? - url = (new ComponentUrl(url)).absolute - triggerEvent('page:before-full-refresh', url: url) - activeDocument.location.href = url - return - - @pushState: (state, title, url) -> - window.history.pushState(state, title, url) - - @replaceState: (state, title, url) -> - window.history.replaceState(state, title, url) - - @document: (documentToUse) -> - activeDocument = documentToUse if documentToUse - activeDocument - - fetchReplacement = (url, options) -> - triggerEvent 'page:fetch', url: url.absolute - - if xhr? - # Workaround for sinon xhr.abort() - # https://github.com/sinonjs/sinon/issues/432#issuecomment-216917023 - xhr.readyState = 0 - xhr.statusText = "abort" - xhr.abort() - - xhr = new XMLHttpRequest - - xhr.open 'GET', url.withoutHashForIE10compatibility(), true - xhr.setRequestHeader 'Accept', 'text/html, application/xhtml+xml, application/xml' - xhr.setRequestHeader 'X-XHR-Referer', referer - options.headers ?= {} - - for k,v of options.headers - xhr.setRequestHeader k, v - - xhr.onload = -> - if xhr.status >= 500 - Turbolinks.fullPageNavigate(url) - else - Turbolinks.loadPage(url, xhr, options) - xhr = null - - xhr.onerror = -> - # Workaround for sinon xhr.abort() - if xhr.statusText == "abort" - xhr = null - return - Turbolinks.fullPageNavigate(url) - - xhr.send() - - return - - @loadPage: (url, xhr, options = {}) -> - triggerEvent 'page:receive' - response = new Response(xhr, url) - options.updatePushState ?= true - options.partialReplace = isPartialReplace(response, options) - - unless upstreamDocument = response.document() - triggerEvent 'page:error', xhr - Turbolinks.fullPageNavigate(response.finalURL) - return - - if options.partialReplace - updateBody(upstreamDocument, response, options) - return - - turbohead = new TurboHead(activeDocument, upstreamDocument) - if turbohead.hasAssetConflicts() - return Turbolinks.fullPageNavigate(response.finalURL) - - turbohead.waitForAssets().then((result) -> - updateBody(upstreamDocument, response, options) unless result?.isCanceled - ) - - updateBody = (upstreamDocument, response, options) -> - nodes = changePage( - upstreamDocument.querySelector('title')?.textContent, - removeNoscriptTags(upstreamDocument.querySelector('body')), - CSRFToken.get(upstreamDocument).token, - 'runScripts', - options - ) - reflectNewUrl(response.finalURL) if options.updatePushState - - Turbolinks.resetScrollPosition() unless options.partialReplace - - options.callback?() - triggerEvent 'page:load', nodes - - changePage = (title, body, csrfToken, runScripts, options = {}) -> - activeDocument.title = title if title - - if options.onlyKeys?.length - nodesToRefresh = [].concat(getNodesWithRefreshAlways(), getNodesMatchingRefreshKeys(options.onlyKeys)) - nodes = refreshNodes(nodesToRefresh, body) - setAutofocusElement() if anyAutofocusElement(nodes) - return nodes - else - refreshNodes(getNodesWithRefreshAlways(), body) - persistStaticElements(body) - if options.exceptKeys?.length - refreshAllExceptWithKeys(options.exceptKeys, body) - else - deleteRefreshNeverNodes(body) - - triggerEvent 'page:before-replace' - replaceNode(body, activeDocument.body) - CSRFToken.update csrfToken if csrfToken? - setAutofocusElement() - executeScriptTags() if runScripts - currentState = window.history.state - triggerEvent 'page:change' - triggerEvent 'page:update' - - return - - getNodesMatchingRefreshKeys = (keys) -> - matchingNodes = [] - for key in keys - for node in TurboGraft.querySelectorAllTGAttribute(activeDocument, 'refresh', key) - matchingNodes.push(node) - - return matchingNodes - - getNodesWithRefreshAlways = -> - matchingNodes = [] - for node in TurboGraft.querySelectorAllTGAttribute(activeDocument, 'refresh-always') - matchingNodes.push(node) - - return matchingNodes - - anyAutofocusElement = (nodes) -> - for node in nodes - if node.querySelectorAll('input[autofocus], textarea[autofocus]').length > 0 - return true - - false - - setAutofocusElement = -> - autofocusElement = (list = activeDocument.querySelectorAll 'input[autofocus], textarea[autofocus]')[list.length - 1] - if autofocusElement and activeDocument.activeElement isnt autofocusElement - autofocusElement.focus() - - deleteRefreshNeverNodes = (body) -> - for node in TurboGraft.querySelectorAllTGAttribute(body, 'refresh-never') - removeNode(node) - - return - - refreshNodes = (allNodesToBeRefreshed, body) -> - triggerEvent 'page:before-partial-replace', allNodesToBeRefreshed - - parentIsRefreshing = (node) -> - for potentialParent in allNodesToBeRefreshed when node != potentialParent - return true if potentialParent.contains(node) - false - - refreshedNodes = [] - for existingNode in allNodesToBeRefreshed - continue if parentIsRefreshing(existingNode) - - unless nodeId = existingNode.getAttribute('id') - throw new Error "Turbolinks refresh: Refresh key elements must have an id." - - if newNode = body.querySelector("##{ nodeId }") - newNode = newNode.cloneNode(true) - replaceNode(newNode, existingNode) - - if newNode.nodeName == 'SCRIPT' && newNode.dataset.turbolinksEval != "false" - executeScriptTag(newNode) - else - refreshedNodes.push(newNode) - - else if !TurboGraft.hasTGAttribute(existingNode, "refresh-always") - removeNode(existingNode) - - refreshedNodes - - keepNodes = (body, allNodesToKeep) -> - for existingNode in allNodesToKeep - unless nodeId = existingNode.getAttribute('id') - throw new Error("TurboGraft refresh: Kept nodes must have an id.") - - if remoteNode = body.querySelector("##{ nodeId }") - replaceNode(existingNode, remoteNode) - - persistStaticElements = (body) -> - allNodesToKeep = [] - - nodes = TurboGraft.querySelectorAllTGAttribute(activeDocument, 'tg-static') - allNodesToKeep.push(node) for node in nodes - - keepNodes(body, allNodesToKeep) - return - - refreshAllExceptWithKeys = (keys, body) -> - allNodesToKeep = [] - - for key in keys - for node in TurboGraft.querySelectorAllTGAttribute(activeDocument, 'refresh', key) - allNodesToKeep.push(node) - - keepNodes(body, allNodesToKeep) - return - - executeScriptTags = -> - scripts = Array::slice.call activeDocument.body.querySelectorAll 'script:not([data-turbolinks-eval="false"])' - for script in scripts when script.type in ['', 'text/javascript'] - executeScriptTag(script) - return - - executeScriptTag = (script) -> - copy = activeDocument.createElement 'script' - copy.setAttribute attr.name, attr.value for attr in script.attributes - copy.appendChild activeDocument.createTextNode script.innerHTML - { parentNode, nextSibling } = script - parentNode.removeChild script - parentNode.insertBefore copy, nextSibling - return - - removeNoscriptTags = (node) -> - node.innerHTML = node.innerHTML.replace //ig, '' - node - - reflectNewUrl = (url) -> - if (url = new ComponentUrl url).absolute isnt referer - Turbolinks.pushState { turbolinks: true, url: url.absolute }, '', url.absolute - return - - rememberReferer = -> - referer = activeDocument.location.href - - @rememberCurrentUrl: -> - Turbolinks.replaceState { turbolinks: true, url: activeDocument.location.href }, '', activeDocument.location.href - - @rememberCurrentState: -> - currentState = window.history.state - - recallScrollPosition = (page) -> - window.scrollTo page.positionX, page.positionY - - @resetScrollPosition: -> - if activeDocument.location.hash - activeDocument.location.href = activeDocument.location.href - else - window.scrollTo 0, 0 - - pageChangePrevented = (url) -> - !triggerEvent('page:before-change', url) - - installHistoryChangeHandler = (event) -> - if event.state?.turbolinks - Turbolinks.visit event.target.location.href - - # Delay execution of function long enough to miss the popstate event - # some browsers fire on the initial page load. - bypassOnLoadPopstate = (fn) -> - setTimeout fn, 500 - - if browserSupportsTurbolinks - @visit = fetch - @rememberCurrentUrl() - @rememberCurrentState() - - activeDocument.addEventListener 'click', Click.installHandlerLast, true - - bypassOnLoadPopstate -> - window.addEventListener 'popstate', installHistoryChangeHandler, false - - else - @visit = (url) -> activeDocument.location.href = url diff --git a/lib/assets/javascripts/turbograft/turbolinks.js b/lib/assets/javascripts/turbograft/turbolinks.js new file mode 100644 index 00000000..5d3af1cc --- /dev/null +++ b/lib/assets/javascripts/turbograft/turbolinks.js @@ -0,0 +1,504 @@ +(function() { + var Response, TurboHead, activeDocument, browserSupportsCustomEvents, browserSupportsPushState, browserSupportsTurbolinks, historyStateIsDefined, installDocumentReadyPageEventTriggers, installJqueryAjaxSuccessPageUpdateTrigger, jQuery, popCookie, ref, removeNode, replaceNode, requestMethodIsSafe, xhr; + + Response = TurboGraft.Response; + + TurboHead = TurboGraft.TurboHead; + + jQuery = window.jQuery; + + xhr = null; + + activeDocument = document; + + installDocumentReadyPageEventTriggers = function() { + return activeDocument.addEventListener('DOMContentLoaded', (function() { + triggerEvent('page:change'); + return triggerEvent('page:update'); + }), true); + }; + + installJqueryAjaxSuccessPageUpdateTrigger = function() { + if (typeof jQuery !== 'undefined') { + return jQuery(activeDocument).on('ajaxSuccess', function(event, xhr, settings) { + if (!jQuery.trim(xhr.responseText)) { + return; + } + return triggerEvent('page:update'); + }); + } + }; + + historyStateIsDefined = window.history.state !== void 0 || navigator.userAgent.match(/Firefox\/2[6|7]/); + + browserSupportsPushState = window.history && window.history.pushState && window.history.replaceState && historyStateIsDefined; + + window.triggerEvent = function(name, data) { + var event; + event = activeDocument.createEvent('Events'); + if (data) { + event.data = data; + } + event.initEvent(name, true, true); + return activeDocument.dispatchEvent(event); + }; + + window.triggerEventFor = function(name, node, data) { + var event; + event = activeDocument.createEvent('Events'); + if (data) { + event.data = data; + } + event.initEvent(name, true, true); + return node.dispatchEvent(event); + }; + + popCookie = function(name) { + var ref, value; + value = ((ref = activeDocument.cookie.match(new RegExp(name + "=(\\w+)"))) != null ? ref[1].toUpperCase() : void 0) || ''; + activeDocument.cookie = name + '=; expires=Thu, 01-Jan-70 00:00:01 GMT; path=/'; + return value; + }; + + requestMethodIsSafe = (ref = popCookie('request_method')) === 'GET' || ref === ''; + + browserSupportsTurbolinks = browserSupportsPushState && requestMethodIsSafe; + + browserSupportsCustomEvents = activeDocument.addEventListener && activeDocument.createEvent; + + if (browserSupportsCustomEvents) { + installDocumentReadyPageEventTriggers(); + installJqueryAjaxSuccessPageUpdateTrigger(); + } + + replaceNode = function(newNode, oldNode) { + var replacedNode; + replacedNode = oldNode.parentNode.replaceChild(newNode, oldNode); + return triggerEvent('page:after-node-removed', replacedNode); + }; + + removeNode = function(node) { + var removedNode; + removedNode = node.parentNode.removeChild(node); + return triggerEvent('page:after-node-removed', removedNode); + }; + + /* TODO: triggerEvent should be accessible to all these guys + * on some kind of eventbus + * TODO: clean up everything above me ^ + * TODO: decide on the public API + */ + window.Turbolinks = (function() { + var anyAutofocusElement, bypassOnLoadPopstate, changePage, currentState, deleteRefreshNeverNodes, executeScriptTag, executeScriptTags, fetch, fetchReplacement, getNodesMatchingRefreshKeys, getNodesWithRefreshAlways, installHistoryChangeHandler, isPartialReplace, keepNodes, pageChangePrevented, persistStaticElements, recallScrollPosition, referer, reflectNewUrl, refreshAllExceptWithKeys, refreshNodes, rememberReferer, removeNoscriptTags, setAutofocusElement, updateBody; + + function Turbolinks() {} + + currentState = null; + + referer = null; + + fetch = function(url, options) { + if (options == null) { + options = {}; + } + if (pageChangePrevented(url)) { + return; + } + url = new ComponentUrl(url); + rememberReferer(); + return fetchReplacement(url, options); + }; + + isPartialReplace = function(response, options) { + var ref1, ref2; + return Boolean(options.partialReplace || ((ref1 = options.onlyKeys) != null ? ref1.length : void 0) || ((ref2 = options.exceptKeys) != null ? ref2.length : void 0)); + }; + + Turbolinks.fullPageNavigate = function(url) { + if (url != null) { + url = (new ComponentUrl(url)).absolute; + triggerEvent('page:before-full-refresh', { + url: url + }); + activeDocument.location.href = url; + } + }; + + Turbolinks.pushState = function(state, title, url) { + return window.history.pushState(state, title, url); + }; + + Turbolinks.replaceState = function(state, title, url) { + return window.history.replaceState(state, title, url); + }; + + Turbolinks.document = function(documentToUse) { + if (documentToUse) { + activeDocument = documentToUse; + } + return activeDocument; + }; + + fetchReplacement = function(url, options) { + var k, ref1, v; + triggerEvent('page:fetch', { + url: url.absolute + }); + if (xhr != null) { + // Workaround for sinon xhr.abort() + // https://github.com/sinonjs/sinon/issues/432#issuecomment-216917023 + xhr.readyState = 0; + xhr.statusText = "abort"; + xhr.abort(); + } + xhr = new XMLHttpRequest; + xhr.open('GET', url.withoutHashForIE10compatibility(), true); + xhr.setRequestHeader('Accept', 'text/html, application/xhtml+xml, application/xml'); + xhr.setRequestHeader('X-XHR-Referer', referer); + if (options.headers == null) { + options.headers = {}; + } + ref1 = options.headers; + for (k in ref1) { + v = ref1[k]; + xhr.setRequestHeader(k, v); + } + xhr.onload = function() { + if (xhr.status >= 500) { + Turbolinks.fullPageNavigate(url); + } else { + Turbolinks.loadPage(url, xhr, options); + } + return xhr = null; + }; + xhr.onerror = function() { + // Workaround for sinon xhr.abort() + if (xhr.statusText === "abort") { + xhr = null; + return; + } + return Turbolinks.fullPageNavigate(url); + }; + xhr.send(); + }; + + Turbolinks.loadPage = function(url, xhr, options) { + var response, turbohead, upstreamDocument; + if (options == null) { + options = {}; + } + triggerEvent('page:receive'); + response = new Response(xhr, url); + if (options.updatePushState == null) { + options.updatePushState = true; + } + options.partialReplace = isPartialReplace(response, options); + if (!(upstreamDocument = response.document())) { + triggerEvent('page:error', xhr); + Turbolinks.fullPageNavigate(response.finalURL); + return; + } + if (options.partialReplace) { + updateBody(upstreamDocument, response, options); + return; + } + turbohead = new TurboHead(activeDocument, upstreamDocument); + if (turbohead.hasAssetConflicts()) { + return Turbolinks.fullPageNavigate(response.finalURL); + } + return turbohead.waitForAssets().then(function(result) { + if (!(result != null ? result.isCanceled : void 0)) { + return updateBody(upstreamDocument, response, options); + } + }); + }; + + updateBody = function(upstreamDocument, response, options) { + var nodes, ref1; + nodes = changePage((ref1 = upstreamDocument.querySelector('title')) != null ? ref1.textContent : void 0, removeNoscriptTags(upstreamDocument.querySelector('body')), CSRFToken.get(upstreamDocument).token, 'runScripts', options); + if (options.updatePushState) { + reflectNewUrl(response.finalURL); + } + if (!options.partialReplace) { + Turbolinks.resetScrollPosition(); + } + if (typeof options.callback === "function") { + options.callback(); + } + return triggerEvent('page:load', nodes); + }; + + changePage = function(title, body, csrfToken, runScripts, options) { + var nodes, nodesToRefresh, ref1, ref2; + if (options == null) { + options = {}; + } + if (title) { + activeDocument.title = title; + } + if ((ref1 = options.onlyKeys) != null ? ref1.length : void 0) { + nodesToRefresh = [].concat(getNodesWithRefreshAlways(), getNodesMatchingRefreshKeys(options.onlyKeys)); + nodes = refreshNodes(nodesToRefresh, body); + if (anyAutofocusElement(nodes)) { + setAutofocusElement(); + } + return nodes; + } else { + refreshNodes(getNodesWithRefreshAlways(), body); + persistStaticElements(body); + if ((ref2 = options.exceptKeys) != null ? ref2.length : void 0) { + refreshAllExceptWithKeys(options.exceptKeys, body); + } else { + deleteRefreshNeverNodes(body); + } + triggerEvent('page:before-replace'); + replaceNode(body, activeDocument.body); + if (csrfToken != null) { + CSRFToken.update(csrfToken); + } + setAutofocusElement(); + if (runScripts) { + executeScriptTags(); + } + currentState = window.history.state; + triggerEvent('page:change'); + triggerEvent('page:update'); + } + }; + + getNodesMatchingRefreshKeys = function(keys) { + var i, j, key, len, len1, matchingNodes, node, ref1; + matchingNodes = []; + for (i = 0, len = keys.length; i < len; i++) { + key = keys[i]; + ref1 = TurboGraft.querySelectorAllTGAttribute(activeDocument, 'refresh', key); + for (j = 0, len1 = ref1.length; j < len1; j++) { + node = ref1[j]; + matchingNodes.push(node); + } + } + return matchingNodes; + }; + + getNodesWithRefreshAlways = function() { + var i, len, matchingNodes, node, ref1; + matchingNodes = []; + ref1 = TurboGraft.querySelectorAllTGAttribute(activeDocument, 'refresh-always'); + for (i = 0, len = ref1.length; i < len; i++) { + node = ref1[i]; + matchingNodes.push(node); + } + return matchingNodes; + }; + + anyAutofocusElement = function(nodes) { + var i, len, node; + for (i = 0, len = nodes.length; i < len; i++) { + node = nodes[i]; + if (node.querySelectorAll('input[autofocus], textarea[autofocus]').length > 0) { + return true; + } + } + return false; + }; + + setAutofocusElement = function() { + var autofocusElement, list; + autofocusElement = (list = activeDocument.querySelectorAll('input[autofocus], textarea[autofocus]'))[list.length - 1]; + if (autofocusElement && activeDocument.activeElement !== autofocusElement) { + return autofocusElement.focus(); + } + }; + + deleteRefreshNeverNodes = function(body) { + var i, len, node, ref1; + ref1 = TurboGraft.querySelectorAllTGAttribute(body, 'refresh-never'); + for (i = 0, len = ref1.length; i < len; i++) { + node = ref1[i]; + removeNode(node); + } + }; + + refreshNodes = function(allNodesToBeRefreshed, body) { + var existingNode, i, len, newNode, nodeId, parentIsRefreshing, refreshedNodes; + triggerEvent('page:before-partial-replace', allNodesToBeRefreshed); + parentIsRefreshing = function(node) { + var i, len, potentialParent; + for (i = 0, len = allNodesToBeRefreshed.length; i < len; i++) { + potentialParent = allNodesToBeRefreshed[i]; + if (node !== potentialParent) { + if (potentialParent.contains(node)) { + return true; + } + } + } + return false; + }; + refreshedNodes = []; + for (i = 0, len = allNodesToBeRefreshed.length; i < len; i++) { + existingNode = allNodesToBeRefreshed[i]; + if (parentIsRefreshing(existingNode)) { + continue; + } + if (!(nodeId = existingNode.getAttribute('id'))) { + throw new Error("Turbolinks refresh: Refresh key elements must have an id."); + } + if (newNode = body.querySelector("#" + nodeId)) { + newNode = newNode.cloneNode(true); + replaceNode(newNode, existingNode); + if (newNode.nodeName === 'SCRIPT' && newNode.dataset.turbolinksEval !== "false") { + executeScriptTag(newNode); + } else { + refreshedNodes.push(newNode); + } + } else if (!TurboGraft.hasTGAttribute(existingNode, "refresh-always")) { + removeNode(existingNode); + } + } + return refreshedNodes; + }; + + keepNodes = function(body, allNodesToKeep) { + var existingNode, i, len, nodeId, remoteNode, results; + results = []; + for (i = 0, len = allNodesToKeep.length; i < len; i++) { + existingNode = allNodesToKeep[i]; + if (!(nodeId = existingNode.getAttribute('id'))) { + throw new Error("TurboGraft refresh: Kept nodes must have an id."); + } + if (remoteNode = body.querySelector("#" + nodeId)) { + results.push(replaceNode(existingNode, remoteNode)); + } else { + results.push(void 0); + } + } + return results; + }; + + persistStaticElements = function(body) { + var allNodesToKeep, i, len, node, nodes; + allNodesToKeep = []; + nodes = TurboGraft.querySelectorAllTGAttribute(activeDocument, 'tg-static'); + for (i = 0, len = nodes.length; i < len; i++) { + node = nodes[i]; + allNodesToKeep.push(node); + } + keepNodes(body, allNodesToKeep); + }; + + refreshAllExceptWithKeys = function(keys, body) { + var allNodesToKeep, i, j, key, len, len1, node, ref1; + allNodesToKeep = []; + for (i = 0, len = keys.length; i < len; i++) { + key = keys[i]; + ref1 = TurboGraft.querySelectorAllTGAttribute(activeDocument, 'refresh', key); + for (j = 0, len1 = ref1.length; j < len1; j++) { + node = ref1[j]; + allNodesToKeep.push(node); + } + } + keepNodes(body, allNodesToKeep); + }; + + executeScriptTags = function() { + var i, len, ref1, script, scripts; + scripts = Array.prototype.slice.call(activeDocument.body.querySelectorAll('script:not([data-turbolinks-eval="false"])')); + for (i = 0, len = scripts.length; i < len; i++) { + script = scripts[i]; + if ((ref1 = script.type) === '' || ref1 === 'text/javascript') { + executeScriptTag(script); + } + } + }; + + executeScriptTag = function(script) { + var attr, copy, i, len, nextSibling, parentNode, ref1; + copy = activeDocument.createElement('script'); + ref1 = script.attributes; + for (i = 0, len = ref1.length; i < len; i++) { + attr = ref1[i]; + copy.setAttribute(attr.name, attr.value); + } + copy.appendChild(activeDocument.createTextNode(script.innerHTML)); + parentNode = script.parentNode, nextSibling = script.nextSibling; + parentNode.removeChild(script); + parentNode.insertBefore(copy, nextSibling); + }; + + removeNoscriptTags = function(node) { + node.innerHTML = node.innerHTML.replace(//ig, ''); + return node; + }; + + reflectNewUrl = function(url) { + if ((url = new ComponentUrl(url)).absolute !== referer) { + Turbolinks.pushState({ + turbolinks: true, + url: url.absolute + }, '', url.absolute); + } + }; + + rememberReferer = function() { + return referer = activeDocument.location.href; + }; + + Turbolinks.rememberCurrentUrl = function() { + return Turbolinks.replaceState({ + turbolinks: true, + url: activeDocument.location.href + }, '', activeDocument.location.href); + }; + + Turbolinks.rememberCurrentState = function() { + return currentState = window.history.state; + }; + + recallScrollPosition = function(page) { + return window.scrollTo(page.positionX, page.positionY); + }; + + Turbolinks.resetScrollPosition = function() { + if (activeDocument.location.hash) { + return activeDocument.location.href = activeDocument.location.href; + } else { + return window.scrollTo(0, 0); + } + }; + + pageChangePrevented = function(url) { + return !triggerEvent('page:before-change', url); + }; + + installHistoryChangeHandler = function(event) { + var ref1; + if ((ref1 = event.state) != null ? ref1.turbolinks : void 0) { + return Turbolinks.visit(event.target.location.href); + } + }; + + // Delay execution of function long enough to miss the popstate event + // some browsers fire on the initial page load. + bypassOnLoadPopstate = function(fn) { + return setTimeout(fn, 500); + }; + + if (browserSupportsTurbolinks) { + Turbolinks.visit = fetch; + Turbolinks.rememberCurrentUrl(); + Turbolinks.rememberCurrentState(); + activeDocument.addEventListener('click', Click.installHandlerLast, true); + bypassOnLoadPopstate(function() { + return window.addEventListener('popstate', installHistoryChangeHandler, false); + }); + } else { + Turbolinks.visit = function(url) { + return activeDocument.location.href = url; + }; + } + + return Turbolinks; + + })(); + +}).call(this); diff --git a/test/example/test/teaspoon_env.rb b/test/example/test/teaspoon_env.rb index 33d8908e..8bd57688 100644 --- a/test/example/test/teaspoon_env.rb +++ b/test/example/test/teaspoon_env.rb @@ -12,7 +12,7 @@ config.suite do |suite| suite.use_framework :mocha - suite.matcher = "{test/javascripts,app/assets}/**/*_test.{js,js.coffee,coffee}" + suite.matcher = "{test/javascripts,app/assets}/**/*_test.js" suite.helper = "test_helper" suite.stylesheets = ["teaspoon"] end diff --git a/test/javascripts/component_url_test.coffee b/test/javascripts/component_url_test.coffee deleted file mode 100644 index b2df0e5c..00000000 --- a/test/javascripts/component_url_test.coffee +++ /dev/null @@ -1,36 +0,0 @@ -describe 'ComponentUrl', -> - describe 'constructor', -> - it 'uses current location when not given a url', -> - url = new ComponentUrl() - assert.equal(url.absolute, location.href) - - it 'does a noop and returns argument, if already a ComponentUrl', -> - url = new ComponentUrl("http://example.com") - url2 = new ComponentUrl(url) - - assert.equal url, url2 - - it 'parses the URL provided', -> - url = new ComponentUrl("http://example.com:90/foo/bar?query=string#yolo") - assert.equal "http://example.com:90", url.origin - assert.equal "?query=string", url.search - assert.equal "http:", url.protocol - assert.equal "example.com:90", url.host - assert.equal "example.com", url.hostname - assert.equal 90, url.port - assert.equal "/foo/bar", url.pathname - assert.equal "#yolo", url.hash - - describe 'withoutHash', -> - it 'returns the URL without the hash', -> - url = new ComponentUrl("http://yo.lo#shipit") - assert.equal "http://yo.lo/", url.withoutHash() - - describe 'hasNoHash', -> - it 'returns true when there is no hash', -> - url = new ComponentUrl("http://example.com") - assert url.hasNoHash() - - it 'returns false when there is a hash', -> - url = new ComponentUrl("http://example.com#test") - assert !url.hasNoHash() diff --git a/test/javascripts/component_url_test.js b/test/javascripts/component_url_test.js new file mode 100644 index 00000000..05d401a4 --- /dev/null +++ b/test/javascripts/component_url_test.js @@ -0,0 +1,49 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +describe('ComponentUrl', function() { + describe('constructor', function() { + it('uses current location when not given a url', function() { + const url = new ComponentUrl(); + return assert.equal(url.absolute, location.href); + }); + + it('does a noop and returns argument, if already a ComponentUrl', function() { + const url = new ComponentUrl("http://example.com"); + const url2 = new ComponentUrl(url); + + return assert.equal(url, url2); + }); + + return it('parses the URL provided', function() { + const url = new ComponentUrl("http://example.com:90/foo/bar?query=string#yolo"); + assert.equal("http://example.com:90", url.origin); + assert.equal("?query=string", url.search); + assert.equal("http:", url.protocol); + assert.equal("example.com:90", url.host); + assert.equal("example.com", url.hostname); + assert.equal(90, url.port); + assert.equal("/foo/bar", url.pathname); + return assert.equal("#yolo", url.hash); + }); + }); + + describe('withoutHash', () => it('returns the URL without the hash', function() { + const url = new ComponentUrl("http://yo.lo#shipit"); + return assert.equal("http://yo.lo/", url.withoutHash()); + })); + + return describe('hasNoHash', function() { + it('returns true when there is no hash', function() { + const url = new ComponentUrl("http://example.com"); + return assert(url.hasNoHash()); + }); + + return it('returns false when there is a hash', function() { + const url = new ComponentUrl("http://example.com#test"); + return assert(!url.hasNoHash()); + }); + }); +}); diff --git a/test/javascripts/csrf_token_test.coffee b/test/javascripts/csrf_token_test.coffee deleted file mode 100644 index e8da5f3a..00000000 --- a/test/javascripts/csrf_token_test.coffee +++ /dev/null @@ -1,15 +0,0 @@ -describe 'CSRFToken', -> - beforeEach -> - $meta = $("").attr("name", "csrf-token").attr("id", "meta-tag").attr("content", "original") - $("meta[name='csrf-token']").remove() - $("head").append($meta) - - afterEach -> - $("#meta-tag").remove() - - it 'can get the CSRF token', -> - assert.equal CSRFToken.get().token, "original" - - it 'can update the CSRF token', -> - CSRFToken.update("updated_value") - assert.equal CSRFToken.get().token, "updated_value" diff --git a/test/javascripts/csrf_token_test.js b/test/javascripts/csrf_token_test.js new file mode 100644 index 00000000..16047f8b --- /dev/null +++ b/test/javascripts/csrf_token_test.js @@ -0,0 +1,21 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +describe('CSRFToken', function() { + beforeEach(function() { + const $meta = $("").attr("name", "csrf-token").attr("id", "meta-tag").attr("content", "original"); + $("meta[name='csrf-token']").remove(); + return $("head").append($meta); + }); + + afterEach(() => $("#meta-tag").remove()); + + it('can get the CSRF token', () => assert.equal(CSRFToken.get().token, "original")); + + return it('can update the CSRF token', function() { + CSRFToken.update("updated_value"); + return assert.equal(CSRFToken.get().token, "updated_value"); + }); +}); diff --git a/test/javascripts/document_test.coffee b/test/javascripts/document_test.coffee deleted file mode 100644 index 00d9563b..00000000 --- a/test/javascripts/document_test.coffee +++ /dev/null @@ -1,27 +0,0 @@ -describe 'TurboGraft.Document', -> - it 'is defined', -> - assert(TurboGraft.Document) - - describe '@create', -> - it 'returns a document with the given html when given a full html document', -> - headHTML = '' - bodyHTML = '
merp merp
' - template = "#{headHTML}#{bodyHTML}" - - doc = TurboGraft.Document.create(template) - assert.equal(doc.body.innerHTML, bodyHTML) - assert.equal(doc.head.innerHTML, headHTML) - - it 'returns a document with the given body when given only a body tag', -> - bodyHTML = '
merp merp
' - template = "#{bodyHTML}" - - doc = TurboGraft.Document.create(template) - assert.equal(doc.body.innerHTML, bodyHTML) - - - it 'returns a document with the given html at the root of the body when given a snippet', -> - template = '
merp merp
' - - doc = TurboGraft.Document.create(template) - assert.equal(doc.body.innerHTML, template) diff --git a/test/javascripts/document_test.js b/test/javascripts/document_test.js new file mode 100644 index 00000000..3b9897fb --- /dev/null +++ b/test/javascripts/document_test.js @@ -0,0 +1,36 @@ +/* + * decaffeinate suggestions: + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +describe('TurboGraft.Document', function() { + it('is defined', () => assert(TurboGraft.Document)); + + return describe('@create', function() { + it('returns a document with the given html when given a full html document', function() { + const headHTML = ''; + const bodyHTML = '
merp merp
'; + const template = `${headHTML}${bodyHTML}`; + + const doc = TurboGraft.Document.create(template); + assert.equal(doc.body.innerHTML, bodyHTML); + return assert.equal(doc.head.innerHTML, headHTML); + }); + + it('returns a document with the given body when given only a body tag', function() { + const bodyHTML = '
merp merp
'; + const template = `${bodyHTML}`; + + const doc = TurboGraft.Document.create(template); + return assert.equal(doc.body.innerHTML, bodyHTML); + }); + + + return it('returns a document with the given html at the root of the body when given a snippet', function() { + const template = '
merp merp
'; + + const doc = TurboGraft.Document.create(template); + return assert.equal(doc.body.innerHTML, template); + }); + }); +}); diff --git a/test/javascripts/fake_document.coffee b/test/javascripts/fake_document.coffee deleted file mode 100644 index c35ab76c..00000000 --- a/test/javascripts/fake_document.coffee +++ /dev/null @@ -1,21 +0,0 @@ -#= require ./fake_script - -window.fakeDocument = (scriptSources) -> - nodes = (fakeScript(src) for src in scriptSources) - newNodes = [] - - return { - createdScripts: newNodes - head: { - appendChild: () -> {} - } - - createElement: () -> - script = fakeScript() - newNodes.push(script) - script - - createTextNode: () -> {} - - querySelectorAll: -> nodes - } diff --git a/test/javascripts/fake_document.js b/test/javascripts/fake_document.js new file mode 100644 index 00000000..fd0a5dd5 --- /dev/null +++ b/test/javascripts/fake_document.js @@ -0,0 +1,29 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +//= require ./fake_script + +window.fakeDocument = function(scriptSources) { + const nodes = (Array.from(scriptSources).map((src) => fakeScript(src))); + const newNodes = []; + + return { + createdScripts: newNodes, + head: { + appendChild() { return {}; } + }, + + createElement() { + const script = fakeScript(); + newNodes.push(script); + return script; + }, + + createTextNode() { return {}; }, + + querySelectorAll() { return nodes; } + }; +}; diff --git a/test/javascripts/fake_script.coffee b/test/javascripts/fake_script.coffee deleted file mode 100644 index f3ad1171..00000000 --- a/test/javascripts/fake_script.coffee +++ /dev/null @@ -1,28 +0,0 @@ -window.fakeScript = (src) -> - listeners = [] - node = { - 'data-turbolinks-track': src - attributes: [{name: 'src', value: src}] - isLoaded: false - src: src - nodeName: 'SCRIPT' - - appendChild: () -> {} - - setAttribute: (name, value) -> - if name == 'src' - @src = value - @attributes.push({name: name, value: value}) - - addEventListener: (eventName, listener) -> - return if eventName != 'load' - listeners.push(listener) - - fireLoaded: () -> - listener({type: 'load'}) for listener in listeners - new Promise (resolve) -> - node.isLoaded = true - setTimeout -> resolve(node) - - removeEventListener: () -> {} - } diff --git a/test/javascripts/fake_script.js b/test/javascripts/fake_script.js new file mode 100644 index 00000000..a4ebf45b --- /dev/null +++ b/test/javascripts/fake_script.js @@ -0,0 +1,41 @@ +/* + * decaffeinate suggestions: + * DS101: Remove unnecessary use of Array.from + * DS102: Remove unnecessary code created because of implicit returns + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +window.fakeScript = function(src) { + let node; + const listeners = []; + return node = { + 'data-turbolinks-track': src, + attributes: [{name: 'src', value: src}], + isLoaded: false, + src, + nodeName: 'SCRIPT', + + appendChild() { return {}; }, + + setAttribute(name, value) { + if (name === 'src') { + this.src = value; + } + return this.attributes.push({name, value}); + }, + + addEventListener(eventName, listener) { + if (eventName !== 'load') { return; } + return listeners.push(listener); + }, + + fireLoaded() { + for (var listener of Array.from(listeners)) { listener({type: 'load'}); } + return new Promise(function(resolve) { + node.isLoaded = true; + return setTimeout(() => resolve(node)); + }); + }, + + removeEventListener() { return {}; } + }; +}; diff --git a/test/javascripts/fixtures/js/routes.coffee b/test/javascripts/fixtures/js/routes.coffee deleted file mode 100644 index 3dad1580..00000000 --- a/test/javascripts/fixtures/js/routes.coffee +++ /dev/null @@ -1,527 +0,0 @@ -window.ROUTES = { - serverError: [ - 500, - {'Content-Type':'text/html'}, - 'error!' - ], - - validationError: [ - 422, - {'Content-Type':'text/html'}, - 'error!' - ], - - noContentType: [ - 500, - {}, - 'error!' - ], - - xhrRedirectedToHeader: [ - 200, - { - 'X-XHR-Redirected-To': 'test-location' - }, - '' - ], - - otherXhrRedirectedToHeader: [ - 200, - { - 'X-XHR-Redirected-To': 'other-location' - }, - '' - ], - - noScriptsOrLinkInHead: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - Hi there! - - -
YOLO
-
Hi bob
- - - """ - ], - - singleScriptInHead: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - Hi there! - - -
- - - """ - ], - - singleScriptInHeadTrackTrue: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - Hi there! - - -
- - - """ - ], - - twoScriptsInHead: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
- - - """ - ], - - twoScriptsInHeadTrackTrue: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
- - - """ - ], - - twoScriptsInHeadTrackTrueOneChanged: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
Merp
- - - """ - ], - - twoScriptsInHeadOneBroken: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
- Text content even though a script broke -
- - - """ - ], - - differentSingleScriptInHead: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - Hi there! - - -
- - - """ - ], - - singleScriptInHeadWithDifferentSourceButSameName: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - Hi there! - - -
- - - """ - ], - - secondLibraryOnly: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - -
- - - """ - ], - - threeScriptsInHeadABC: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - - Hi there! - - -
- - - """ - ], - - threeScriptsInHeadACB: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - - Hi there! - - -
- - - """ - ], - - threeScriptsInHeadBAC: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - - Hi there! - - -
- - - """ - ], - - threeScriptsInHeadBCA: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - - Hi there! - - -
- - - """ - ], - - threeScriptsInHeadCAB: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - - Hi there! - - -
- - - """ - ], - - threeScriptsInHeadCBA: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - - Hi there! - - -
- - - """ - ], - - singleLinkInHead: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - Hi there! - - -
- - - """ - ], - - twoLinksInHead: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
- - - """ - ], - - twoLinksInHeadTrackTrue: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
- - - """ - ], - - twoLinksInHeadTrackTrueOneChanged: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
- - - """ - ], - - twoLinksInHeadReverseOrder: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - - - Hi there! - - -
- - - """ - ], - - inlineScriptInBody: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - Hi - - - - - - """ - ], - - inlineScriptInBodyTurbolinksEvalFalse: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - Hi - - - - - - """ - ], - - responseWithRefreshAlways: [ - 200, - {'Content-Type':'text/html'}, - """ - - - - Hi - - -
-
Refresh-always
-
- - - """ - ] -} diff --git a/test/javascripts/fixtures/js/routes.js b/test/javascripts/fixtures/js/routes.js new file mode 100644 index 00000000..7ee44436 --- /dev/null +++ b/test/javascripts/fixtures/js/routes.js @@ -0,0 +1,527 @@ +window.ROUTES = { + serverError: [ + 500, + {'Content-Type':'text/html'}, + 'error!' + ], + + validationError: [ + 422, + {'Content-Type':'text/html'}, + 'error!' + ], + + noContentType: [ + 500, + {}, + 'error!' + ], + + xhrRedirectedToHeader: [ + 200, + { + 'X-XHR-Redirected-To': 'test-location' + }, + '' + ], + + otherXhrRedirectedToHeader: [ + 200, + { + 'X-XHR-Redirected-To': 'other-location' + }, + '' + ], + + noScriptsOrLinkInHead: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + Hi there! + + +
YOLO
+
Hi bob
+ +\ +` + ], + + singleScriptInHead: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + Hi there! + + +
+ +\ +` + ], + + singleScriptInHeadTrackTrue: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + Hi there! + + +
+ +\ +` + ], + + twoScriptsInHead: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
+ +\ +` + ], + + twoScriptsInHeadTrackTrue: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
+ +\ +` + ], + + twoScriptsInHeadTrackTrueOneChanged: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
Merp
+ +\ +` + ], + + twoScriptsInHeadOneBroken: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
+ Text content even though a script broke +
+ +\ +` + ], + + differentSingleScriptInHead: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + Hi there! + + +
+ +\ +` + ], + + singleScriptInHeadWithDifferentSourceButSameName: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + Hi there! + + +
+ +\ +` + ], + + secondLibraryOnly: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + +
+ +\ +` + ], + + threeScriptsInHeadABC: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + + Hi there! + + +
+ +\ +` + ], + + threeScriptsInHeadACB: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + + Hi there! + + +
+ +\ +` + ], + + threeScriptsInHeadBAC: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + + Hi there! + + +
+ +\ +` + ], + + threeScriptsInHeadBCA: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + + Hi there! + + +
+ +\ +` + ], + + threeScriptsInHeadCAB: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + + Hi there! + + +
+ +\ +` + ], + + threeScriptsInHeadCBA: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + + Hi there! + + +
+ +\ +` + ], + + singleLinkInHead: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + Hi there! + + +
+ +\ +` + ], + + twoLinksInHead: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
+ +\ +` + ], + + twoLinksInHeadTrackTrue: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
+ +\ +` + ], + + twoLinksInHeadTrackTrueOneChanged: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
+ +\ +` + ], + + twoLinksInHeadReverseOrder: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + + + Hi there! + + +
+ +\ +` + ], + + inlineScriptInBody: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + Hi + + + + +\ +` + ], + + inlineScriptInBodyTurbolinksEvalFalse: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + Hi + + + + +\ +` + ], + + responseWithRefreshAlways: [ + 200, + {'Content-Type':'text/html'}, + `\ + + + + Hi + + +
+
Refresh-always
+
+ +\ +` + ] +}; diff --git a/test/javascripts/initializers_test.coffee b/test/javascripts/initializers_test.coffee deleted file mode 100644 index f8099e44..00000000 --- a/test/javascripts/initializers_test.coffee +++ /dev/null @@ -1,238 +0,0 @@ -describe 'Initializers', -> - - # can't figure out why this doesn't work: - describe 'tg-remote on forms', -> - beforeEach -> - @Remote = stub(TurboGraft, "Remote").returns({submit: ->}) - - afterEach -> - @Remote.restore() - - it 'creates a remote based on the options passed in', -> - $form = $("
") - .attr("tg-remote", "true") - .attr("method", "put") - .attr("refresh-on-success", "foo") - .attr("refresh-on-error", "bar") - .attr("full-refresh-on-error-except", "zar") - .attr("full-refresh-on-success-except", "zap") - .attr("action", "somewhere") - $form.append("") - - $("body").append($form) - $form.find("input").trigger("click") - - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "put" - httpUrl: "somewhere" - fullRefresh: false - refreshOnSuccess: "foo" - refreshOnSuccessExcept: "zap" - refreshOnError: "bar" - refreshOnErrorExcept: "zar" - ) - $form.remove() - - describe 'tg-remote on links', -> - beforeEach -> - @Remote = stub(TurboGraft, "Remote").returns({submit: ->}) - - afterEach -> - @Remote.restore() - - it 'creates a remote based on the options passed in', -> - $link = $('') - .attr('tg-remote', 'GET') - .attr('refresh-on-success', 'foo') - .attr('refresh-on-error', 'bar') - .attr('full-refresh-on-error-except', 'zar') - .attr('full-refresh-on-success-except', 'zap') - .attr('href', 'somewhere') - - $('body').append($link) - $link[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: 'GET' - httpUrl: 'somewhere' - fullRefresh: false - refreshOnSuccess: 'foo' - refreshOnSuccessExcept: 'zap' - refreshOnError: 'bar' - refreshOnErrorExcept: 'zar' - ) - - it 'passes through null for missing refresh-on-success', -> - $link = $("") - .attr("tg-remote", "GET") - .attr("refresh-on-error", "bar") - .attr("href", "somewhere") - - $("body").append($link) - $link[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "GET" - httpUrl: "somewhere" - fullRefresh: false - refreshOnSuccess: null - refreshOnSuccessExcept: null - refreshOnError: "bar" - refreshOnErrorExcept: null - ) - - it 'respects tg-remote supplied', -> - $link = $("") - .attr("tg-remote", "PATCH") - .attr("refresh-on-error", "bar") - .attr("href", "somewhere") - - $("body").append($link) - $link[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "PATCH" - httpUrl: "somewhere" - fullRefresh: false - refreshOnSuccess: null - refreshOnSuccessExcept: null - refreshOnError: "bar" - refreshOnErrorExcept: null - ) - - it 'passes through null for missing refresh-on-error', -> - $link = $("") - .attr("tg-remote", "GET") - .attr("refresh-on-success", "foo") - .attr("href", "somewhere") - - $("body").append($link) - $link[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "GET" - httpUrl: "somewhere" - fullRefresh: false - refreshOnSuccess: "foo" - refreshOnSuccessExcept: null - refreshOnError: null - refreshOnErrorExcept: null - ) - - it 'passes through null for missing full-refresh-on-error-except', -> - $link = $("") - .attr("tg-remote", "GET") - .attr("full-refresh-on-error-except", "zew") - .attr("href", "somewhere") - - $("body").append($link) - $link[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "GET" - httpUrl: "somewhere" - fullRefresh: false - refreshOnSuccess: null - refreshOnSuccessExcept: null - refreshOnError: null - refreshOnErrorExcept: 'zew' - ) - - it 'respects full-refresh-on-success-except', -> - $link = $("") - .attr("tg-remote", "GET") - .attr("full-refresh-on-success-except", "zew") - .attr("href", "somewhere") - - $("body").append($link) - $link[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "GET" - httpUrl: "somewhere" - fullRefresh: false - refreshOnSuccess: null - refreshOnSuccessExcept: 'zew' - refreshOnError: null - refreshOnErrorExcept: null - ) - - it 'respects full-refresh', -> - $link = $("") - .attr("full-refresh", true) - .attr("tg-remote", "GET") - .attr("refresh-on-success", "foo") - .attr("refresh-on-error", "bar") - .attr("href", "somewhere") - - $("body").append($link) - $link[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "GET" - httpUrl: "somewhere" - fullRefresh: true - refreshOnSuccess: "foo" - refreshOnSuccessExcept: null - refreshOnError: "bar" - refreshOnErrorExcept: null - ) - - it 'does nothing if disabled', -> - $link = $("") - .attr("disabled", "disabled") - .attr("tg-remote", "GET") - .attr("refresh-on-success", "foo") - .attr("refresh-on-error", "bar") - .attr("href", "somewhere") - - $("body").append($link) - $link[0].click() - assert.notCalled(@Remote) - - it 'clicking on nodes inside of an will bubble correctly', -> - $link = $("foo") - .attr("tg-remote", "PATCH") - .attr("refresh-on-error", "bar") - .attr("href", "somewhere") - - $i = $link.find("i") - - $("body").append($link) - $i[0].click() - assert.called(@Remote) - assert.calledWith(@Remote, - httpRequestType: "PATCH" - httpUrl: "somewhere" - fullRefresh: false - refreshOnSuccess: null - refreshOnSuccessExcept: null - refreshOnError: "bar" - refreshOnErrorExcept: null - ) - - $link.remove() - - it 'clicking on nodes inside of a