├── .gitignore
├── CREDITS.md
├── LICENSE.md
├── README.md
├── dist
├── framework-bundle.js
├── framework-bundle.min.js
├── framework-extras.js
├── framework-extras.min.js
├── framework-turbo.js
├── framework-turbo.min.js
├── framework.js
└── framework.min.js
├── package.json
├── src
├── core
│ ├── controller.js
│ ├── index.js
│ ├── migrate.js
│ ├── namespace.js
│ └── request-builder.js
├── extras
│ ├── attach-loader.js
│ ├── controller.js
│ ├── flash-message.js
│ ├── index.js
│ ├── migrate.js
│ ├── namespace.js
│ ├── progress-bar.js
│ └── validator.js
├── framework-bundle.js
├── framework-extras.js
├── framework-turbo.js
├── framework.js
├── index.js
├── observe
│ ├── application.js
│ ├── container.js
│ ├── context.js
│ ├── control-base.js
│ ├── dispatcher.js
│ ├── event-listener.js
│ ├── index.js
│ ├── module.js
│ ├── mutation
│ │ ├── attribute-observer.js
│ │ ├── element-observer.js
│ │ ├── index.js
│ │ ├── selector-observer.js
│ │ ├── token-list-observer.js
│ │ └── value-list-observer.js
│ ├── namespace.js
│ ├── scope-observer.js
│ ├── scope.js
│ └── util
│ │ ├── multimap.js
│ │ └── set-operations.js
├── request
│ ├── actions.js
│ ├── asset-manager.js
│ ├── data.js
│ ├── index.js
│ ├── namespace.js
│ ├── options.js
│ └── request.js
├── turbo
│ ├── browser-adapter.js
│ ├── controller.js
│ ├── error-renderer.js
│ ├── head-details.js
│ ├── history.js
│ ├── index.js
│ ├── location.js
│ ├── namespace.js
│ ├── renderer.js
│ ├── scroll-manager.js
│ ├── snapshot-cache.js
│ ├── snapshot-renderer.js
│ ├── snapshot.js
│ ├── view.js
│ └── visit.js
└── util
│ ├── deferred.js
│ ├── events.js
│ ├── form-serializer.js
│ ├── http-request.js
│ ├── index.js
│ ├── json-parser.js
│ ├── referrer.js
│ └── wait.js
├── types
└── index.d.ts
├── webpack.config.js
└── webpack.mix.js
/.gitignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 | node_modules
3 |
--------------------------------------------------------------------------------
/CREDITS.md:
--------------------------------------------------------------------------------
1 | # Credits
2 |
3 | This library was created with help from the following packages:
4 |
5 | "Turbo", Copyright (c) 2021 Basecamp
6 | https://github.com/hotwired/turbo
7 |
8 | "Stimulus", Copyright (c) 2021 Basecamp
9 | https://github.com/hotwired/stimulus
10 |
11 | "Twitter Bootstrap", Copyright (c) 2011-2022 Twitter, Inc., Copyright (c) 2011-2022 The Bootstrap Authors
12 | https://github.com/twbs/bootstrap
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # October AJAX Framework
2 |
3 | This repository contains the AJAX framework of October CMS. If you want to build a website using October CMS, visit the main [October CMS repository](http://github.com/octobercms/october).
4 |
5 | ## Installation outside October CMS
6 |
7 | Your application can use the `octobercms` npm package to install the AJAX Framework as a module for build tools like webpack.
8 |
9 | 1. Add the `octobercms` package to your application.
10 |
11 | ```js
12 | npm install --save octobercms
13 | ```
14 |
15 | 2. Require and start the Framework in your JavaScript bundle.
16 |
17 | ```js
18 | import oc from 'octobercms';
19 |
20 | // Make an AJAX request
21 | oc.ajax('onSomething', { data: someVar });
22 |
23 | // Serialize an element with the request
24 | oc.request('.some-element', 'onSomething', { data: someVar });
25 | ```
26 |
27 | ### jQuery Adapter
28 |
29 | If jQuery is found, the traditional API can also be used.
30 |
31 | ```js
32 | // AJAX request with jQuery
33 | $.request('onSomething', { data: someVar });
34 |
35 | // Serialized request with jQuery
36 | $('.some-element').request('onSomething', { data: someVar });
37 | ```
38 |
39 | ## Documentation
40 |
41 | [Read the complete documentation](https://docs.octobercms.com/3.x/cms/ajax/introduction.html) on the October CMS website.
42 |
43 | ## License
44 |
45 | The October CMS platform is licensed software, see [End User License Agreement](./LICENSE.md) (EULA) for more details.
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "octobercms",
3 | "version": "1.3.2",
4 | "description": "AJAX Framework for October CMS",
5 | "main": "src/index.js",
6 | "types": "types/index.d.ts",
7 | "devDependencies": {
8 | "laravel-mix": "^6.0.44"
9 | },
10 | "scripts": {
11 | "dev": "npm run development",
12 | "development": "mix",
13 | "watch": "mix watch",
14 | "prod": "npm run production",
15 | "production": "mix --production",
16 | "pub": "npm run development && npm run production",
17 | "test": "echo \"Error: no test specified\" && exit 1"
18 | },
19 | "repository": {
20 | "type": "git",
21 | "url": "git+https://github.com/octobercms/ajax.git"
22 | },
23 | "keywords": [
24 | "octobercms",
25 | "october",
26 | "ajax",
27 | "framework"
28 | ],
29 | "author": "October CMS (Responsiv Pty Ltd)",
30 | "license": "SEE LICENSE IN LICENSE.md",
31 | "bugs": {
32 | "url": "https://github.com/octobercms/ajax/issues"
33 | },
34 | "homepage": "https://github.com/octobercms/ajax#readme"
35 | }
36 |
--------------------------------------------------------------------------------
/src/core/controller.js:
--------------------------------------------------------------------------------
1 | import { Events } from "../util/events";
2 | import { RequestBuilder } from "./request-builder";
3 |
4 | export class Controller
5 | {
6 | constructor() {
7 | this.started = false;
8 | this.documentVisible = true;
9 | }
10 |
11 | start() {
12 | if (!this.started) {
13 | // Track unload event for request lib
14 | window.onbeforeunload = this.documentOnBeforeUnload;
15 |
16 | // First page load
17 | addEventListener('DOMContentLoaded', () => this.render());
18 |
19 | // Again, after new scripts load
20 | addEventListener('page:updated', () => this.render());
21 |
22 | // Again after AJAX request
23 | addEventListener('ajax:update-complete', () => this.render());
24 |
25 | // Watching document visibility
26 | addEventListener('visibilitychange', () => this.documentOnVisibilityChange());
27 |
28 | // Submit form
29 | Events.on(document, 'submit', '[data-request]', this.documentOnSubmit);
30 |
31 | // Track input
32 | Events.on(document, 'input', 'input[data-request][data-track-input]', this.documentOnKeyup);
33 |
34 | // Change select, checkbox, radio, file input
35 | Events.on(document, 'change', 'select[data-request], input[type=radio][data-request], input[type=checkbox][data-request], input[type=file][data-request]', this.documentOnChange);
36 |
37 | // Press enter on orphan input
38 | Events.on(document, 'keydown', 'input[type=text][data-request], input[type=submit][data-request], input[type=password][data-request]', this.documentOnKeydown);
39 |
40 | // Click submit button or link
41 | Events.on(document, 'click', 'a[data-request], button[data-request], input[type=button][data-request], input[type=submit][data-request]', this.documentOnClick);
42 |
43 | this.started = true;
44 | }
45 | }
46 |
47 | stop() {
48 | if (this.started) {
49 | this.started = false;
50 | }
51 | }
52 |
53 | render(event) {
54 | // Pre render event, used to move nodes around
55 | Events.dispatch('before-render');
56 |
57 | // Render event, used to initialize controls
58 | Events.dispatch('render');
59 |
60 | // Resize event to adjust all measurements
61 | dispatchEvent(new Event('resize'));
62 |
63 | this.documentOnRender(event);
64 | }
65 |
66 | documentOnVisibilityChange(event) {
67 | this.documentVisible = !document.hidden;
68 | if (this.documentVisible) {
69 | this.documentOnRender();
70 | }
71 | }
72 |
73 | documentOnRender(event) {
74 | if (!this.documentVisible) {
75 | return;
76 | }
77 |
78 | document.querySelectorAll('[data-auto-submit]').forEach(function(el) {
79 | const interval = el.dataset.autoSubmit || 0;
80 | el.removeAttribute('data-auto-submit');
81 | setTimeout(function() {
82 | RequestBuilder.fromElement(el);
83 | }, interval);
84 | });
85 | }
86 |
87 | documentOnSubmit(event) {
88 | event.preventDefault();
89 | RequestBuilder.fromElement(event.target);
90 | }
91 |
92 | documentOnClick(event) {
93 | event.preventDefault();
94 | RequestBuilder.fromElement(event.target);
95 | }
96 |
97 | documentOnChange(event) {
98 | RequestBuilder.fromElement(event.target);
99 | }
100 |
101 | documentOnKeyup(event) {
102 | var el = event.target,
103 | lastValue = el.dataset.ocLastValue;
104 |
105 | if (['email', 'number', 'password', 'search', 'text'].indexOf(el.type) === -1) {
106 | return;
107 | }
108 |
109 | if (lastValue !== undefined && lastValue == el.value) {
110 | return;
111 | }
112 |
113 | el.dataset.ocLastValue = el.value;
114 |
115 | if (this.dataTrackInputTimer !== undefined) {
116 | clearTimeout(this.dataTrackInputTimer);
117 | }
118 |
119 | var interval = el.getAttribute('data-track-input');
120 | if (!interval) {
121 | interval = 300;
122 | }
123 |
124 | var self = this;
125 | this.dataTrackInputTimer = setTimeout(function() {
126 | if (self.lastDataTrackInputRequest) {
127 | self.lastDataTrackInputRequest.abort();
128 | }
129 |
130 | self.lastDataTrackInputRequest = RequestBuilder.fromElement(el);
131 | }, interval);
132 | }
133 |
134 | documentOnKeydown(event) {
135 | if (event.key === 'Enter') {
136 | event.preventDefault();
137 |
138 | if (this.dataTrackInputTimer !== undefined) {
139 | clearTimeout(this.dataTrackInputTimer);
140 | }
141 |
142 | RequestBuilder.fromElement(event.target);
143 | }
144 | }
145 |
146 | documentOnBeforeUnload(event) {
147 | window.ocUnloading = true;
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/core/index.js:
--------------------------------------------------------------------------------
1 | import { Events } from "../util/events";
2 | import { waitFor, domReady } from "../util/wait";
3 | import namespace from "./namespace";
4 | export default namespace;
5 |
6 | if (!window.oc) {
7 | window.oc = {};
8 | }
9 |
10 | if (!window.oc.AjaxFramework) {
11 | // Namespace
12 | window.oc.AjaxFramework = namespace;
13 |
14 | // Request on element with builder
15 | window.oc.request = namespace.requestElement;
16 |
17 | // JSON parser
18 | window.oc.parseJSON = namespace.parseJSON;
19 |
20 | // Form serializer
21 | window.oc.serializeJSON = namespace.serializeJSON;
22 |
23 | // Selector events
24 | window.oc.Events = Events;
25 |
26 | // Wait for a variable to exist
27 | window.oc.waitFor = waitFor;
28 |
29 | // Fallback for turbo
30 | window.oc.pageReady = domReady;
31 |
32 | // Fallback for turbo
33 | window.oc.visit = (url) => window.location.assign(url);
34 |
35 | // Boot controller
36 | if (!isAMD() && !isCommonJS()) {
37 | namespace.start();
38 | }
39 | }
40 |
41 | function isAMD() {
42 | return typeof define == "function" && define.amd;
43 | }
44 |
45 | function isCommonJS() {
46 | return typeof exports == "object" && typeof module != "undefined";
47 | }
48 |
--------------------------------------------------------------------------------
/src/core/migrate.js:
--------------------------------------------------------------------------------
1 | import { RequestBuilder } from '../core/request-builder';
2 | import { JsonParser } from "../util/json-parser";
3 |
4 | export class Migrate
5 | {
6 | bind() {
7 | this.bindRequestFunc();
8 | this.bindRenderFunc();
9 | this.bindjQueryEvents();
10 | }
11 |
12 | bindRequestFunc() {
13 | var old = $.fn.request;
14 |
15 | $.fn.request = function(handler, option) {
16 | var options = typeof option === 'object' ? option : {};
17 | return new RequestBuilder(this.get(0), handler, options);
18 | }
19 |
20 | $.fn.request.Constructor = RequestBuilder;
21 |
22 | // Basic function
23 | $.request = function(handler, option) {
24 | return $(document).request(handler, option);
25 | }
26 |
27 | // No conflict
28 | $.fn.request.noConflict = function() {
29 | $.fn.request = old;
30 | return this;
31 | }
32 | }
33 |
34 | bindRenderFunc() {
35 | $.fn.render = function(callback) {
36 | $(document).on('render', callback);
37 | };
38 | }
39 |
40 | bindjQueryEvents() {
41 | // Element
42 | this.migratejQueryEvent(document, 'ajax:setup', 'ajaxSetup', ['context']);
43 | this.migratejQueryEvent(document, 'ajax:promise', 'ajaxPromise', ['context']);
44 | this.migratejQueryEvent(document, 'ajax:fail', 'ajaxFail', ['context', 'data', 'responseCode', 'xhr']);
45 | this.migratejQueryEvent(document, 'ajax:done', 'ajaxDone', ['context', 'data', 'responseCode', 'xhr']);
46 | this.migratejQueryEvent(document, 'ajax:always', 'ajaxAlways', ['context', 'data', 'responseCode', 'xhr']);
47 | this.migratejQueryEvent(document, 'ajax:before-redirect', 'ajaxRedirect');
48 |
49 | // Updated Element
50 | this.migratejQueryEvent(document, 'ajax:update', 'ajaxUpdate', ['context', 'data', 'responseCode', 'xhr']);
51 | this.migratejQueryEvent(document, 'ajax:before-replace', 'ajaxBeforeReplace');
52 |
53 | // Trigger Element
54 | this.migratejQueryEvent(document, 'ajax:before-request', 'oc.beforeRequest', ['context']);
55 | this.migratejQueryEvent(document, 'ajax:before-update', 'ajaxBeforeUpdate', ['context', 'data', 'responseCode', 'xhr']);
56 | this.migratejQueryEvent(document, 'ajax:request-success', 'ajaxSuccess', ['context', 'data', 'responseCode', 'xhr']);
57 | this.migratejQueryEvent(document, 'ajax:request-complete', 'ajaxComplete', ['context', 'data', 'responseCode', 'xhr']);
58 | this.migratejQueryEvent(document, 'ajax:request-error', 'ajaxError', ['context', 'message', 'responseCode', 'xhr']);
59 | this.migratejQueryEvent(document, 'ajax:before-validate', 'ajaxValidation', ['context', 'message', 'fields']);
60 |
61 | // Window
62 | this.migratejQueryEvent(window, 'ajax:before-send', 'ajaxBeforeSend', ['context']);
63 | this.migratejQueryEvent(window, 'ajax:update-complete', 'ajaxUpdateComplete', ['context', 'data', 'responseCode', 'xhr']);
64 | this.migratejQueryEvent(window, 'ajax:invalid-field', 'ajaxInvalidField', ['element', 'fieldName', 'errorMsg', 'isFirst']);
65 | this.migratejQueryEvent(window, 'ajax:confirm-message', 'ajaxConfirmMessage', ['message', 'promise']);
66 | this.migratejQueryEvent(window, 'ajax:error-message', 'ajaxErrorMessage', ['message']);
67 |
68 | // Data adapter
69 | this.migratejQueryAttachData(document, 'ajax:setup', 'a[data-request], button[data-request], form[data-request], a[data-handler], button[data-handler]');
70 | }
71 |
72 | // Private
73 | migratejQueryEvent(target, jsName, jqName, detailNames = []) {
74 | var self = this;
75 | $(target).on(jsName, function(ev) {
76 | self.triggerjQueryEvent(ev.originalEvent, jqName, detailNames);
77 | });
78 | }
79 |
80 | triggerjQueryEvent(ev, eventName, detailNames = []) {
81 | var jQueryEvent = $.Event(eventName),
82 | args = this.buildDetailArgs(ev, detailNames);
83 |
84 | $(ev.target).trigger(jQueryEvent, args);
85 |
86 | if (jQueryEvent.isDefaultPrevented()) {
87 | ev.preventDefault();
88 | }
89 | }
90 |
91 | buildDetailArgs(ev, detailNames) {
92 | var args = [];
93 |
94 | detailNames.forEach(function(name) {
95 | args.push(ev.detail[name]);
96 | });
97 |
98 | return args;
99 | }
100 |
101 | // For instances where data() is populated in the jQ instance
102 | migratejQueryAttachData(target, eventName, selector) {
103 | $(target).on(eventName, selector, function(event) {
104 | var dataObj = $(this).data('request-data');
105 | if (!dataObj) {
106 | return;
107 | }
108 |
109 | var options = event.detail.context.options;
110 | if (dataObj.constructor === {}.constructor) {
111 | Object.assign(options.data, dataObj);
112 | }
113 | else if (typeof dataObj === 'string') {
114 | Object.assign(options.data, JsonParser.paramToObj('request-data', dataObj));
115 | }
116 | });
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/core/namespace.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "./controller";
2 | import { Migrate } from "./migrate";
3 | import { RequestBuilder } from "./request-builder";
4 | import { JsonParser } from "../util/json-parser";
5 | import { FormSerializer } from "../util/form-serializer";
6 | const controller = new Controller;
7 |
8 | export default {
9 | controller,
10 |
11 | parseJSON: JsonParser.parseJSON,
12 |
13 | serializeJSON: FormSerializer.serializeJSON,
14 |
15 | requestElement: RequestBuilder.fromElement,
16 |
17 | start() {
18 | controller.start();
19 |
20 | if (window.jQuery) {
21 | (new Migrate).bind();
22 | }
23 | },
24 |
25 | stop() {
26 | controller.stop();
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/src/core/request-builder.js:
--------------------------------------------------------------------------------
1 | import Request from "../request/namespace";
2 | import { JsonParser } from "../util/json-parser";
3 |
4 | export class RequestBuilder
5 | {
6 | constructor(element, handler, options) {
7 | this.options = options || {};
8 | this.ogElement = element;
9 | this.element = this.findElement(element);
10 |
11 | if (!this.element) {
12 | return Request.send(handler, this.options);
13 | }
14 |
15 | this.assignAsEval('beforeUpdateFunc', 'requestBeforeUpdate');
16 | this.assignAsEval('afterUpdateFunc', 'requestAfterUpdate');
17 | this.assignAsEval('successFunc', 'requestSuccess');
18 | this.assignAsEval('errorFunc', 'requestError');
19 | this.assignAsEval('cancelFunc', 'requestCancel');
20 | this.assignAsEval('completeFunc', 'requestComplete');
21 |
22 | this.assignAsData('progressBar', 'requestProgressBar');
23 | this.assignAsData('message', 'requestMessage');
24 | this.assignAsData('confirm', 'requestConfirm');
25 | this.assignAsData('redirect', 'requestRedirect');
26 | this.assignAsData('loading', 'requestLoading');
27 | this.assignAsData('form', 'requestForm');
28 | this.assignAsData('url', 'requestUrl');
29 | this.assignAsData('bulk', 'requestBulk', { emptyAsTrue: true });
30 | this.assignAsData('files', 'requestFiles', { emptyAsTrue: true });
31 | this.assignAsData('flash', 'requestFlash', { emptyAsTrue: true });
32 | this.assignAsData('download', 'requestDownload', { emptyAsTrue: true });
33 | this.assignAsData('update', 'requestUpdate', { parseJson: true });
34 | this.assignAsData('query', 'requestQuery', { emptyAsTrue: true, parseJson: true });
35 |
36 | this.assignAsData('browserTarget', 'browserTarget');
37 | this.assignAsData('browserValidate', 'browserValidate', { emptyAsTrue: true });
38 | this.assignAsData('browserRedirectBack', 'browserRedirectBack', { emptyAsTrue: true });
39 |
40 | this.assignAsMetaData('update', 'ajaxRequestUpdate', { parseJson: true, mergeValue: true });
41 |
42 | this.assignRequestData();
43 |
44 | if (!handler) {
45 | handler = this.getHandlerName();
46 | }
47 |
48 | return Request.sendElement(this.element, handler, this.options);
49 | }
50 |
51 | static fromElement(element, handler, options) {
52 | if (typeof element === 'string') {
53 | element = document.querySelector(element);
54 | }
55 |
56 | return new RequestBuilder(element, handler, options);
57 | }
58 |
59 | // Event target may some random node inside the data-request container
60 | // so it should bubble up but also capture the ogElement in case it is
61 | // a button that contains data-request-data.
62 | findElement(element) {
63 | if (!element || element === document) {
64 | return null;
65 | }
66 |
67 | if (element.matches('[data-request]')) {
68 | return element;
69 | }
70 |
71 | var parentEl = element.closest('[data-request]');
72 | if (parentEl) {
73 | return parentEl;
74 | }
75 |
76 | return element;
77 | }
78 |
79 | getHandlerName() {
80 | if (this.element.dataset.dataRequest) {
81 | return this.element.dataset.dataRequest;
82 | }
83 |
84 | return this.element.getAttribute('data-request');
85 | }
86 |
87 | assignAsEval(optionName, name) {
88 | if (this.options[optionName] !== undefined) {
89 | return;
90 | }
91 |
92 | var attrVal;
93 | if (this.element.dataset[name]) {
94 | attrVal = this.element.dataset[name];
95 | }
96 | else {
97 | attrVal = this.element.getAttribute('data-' + normalizeDataKey(name));
98 | }
99 |
100 | if (!attrVal) {
101 | return;
102 | }
103 |
104 | this.options[optionName] = function(element, data) {
105 | return (new Function('data', attrVal)).apply(element, [data]);
106 | };
107 | }
108 |
109 | assignAsData(optionName, name, { parseJson = false, emptyAsTrue = false } = {}) {
110 | if (this.options[optionName] !== undefined) {
111 | return;
112 | }
113 |
114 | var attrVal;
115 | if (this.element.dataset[name]) {
116 | attrVal = this.element.dataset[name];
117 | }
118 | else {
119 | attrVal = this.element.getAttribute('data-' + normalizeDataKey(name));
120 | }
121 |
122 | if (attrVal === null) {
123 | return;
124 | }
125 |
126 | attrVal = this.castAttrToOption(attrVal, emptyAsTrue);
127 |
128 | if (parseJson && typeof attrVal === 'string') {
129 | attrVal = JsonParser.paramToObj(
130 | 'data-' + normalizeDataKey(name),
131 | attrVal
132 | );
133 | }
134 |
135 | this.options[optionName] = attrVal;
136 | }
137 |
138 | assignAsMetaData(optionName, name, { mergeValue = true, parseJson = false, emptyAsTrue = false } = {}) {
139 | const meta = document.documentElement.querySelector('head meta[name="'+normalizeDataKey(name)+'"]');
140 | if (!meta) {
141 | return;
142 | }
143 |
144 | var attrVal = meta.getAttribute('content');
145 |
146 | if (parseJson) {
147 | attrVal = JsonParser.paramToObj(normalizeDataKey(name), attrVal);
148 | }
149 | else {
150 | attrVal = this.castAttrToOption(attrVal, emptyAsTrue);
151 | }
152 |
153 | if (mergeValue) {
154 | this.options[optionName] = {
155 | ...(this.options[optionName] || {}),
156 | ...attrVal
157 | }
158 | }
159 | else {
160 | this.options[optionName] = attrVal;
161 | }
162 | }
163 |
164 | castAttrToOption(val, emptyAsTrue) {
165 | if (emptyAsTrue && val === '') {
166 | return true;
167 | }
168 |
169 | if (val === 'true' || val === '1') {
170 | return true;
171 | }
172 |
173 | if (val === 'false' || val === '0') {
174 | return false;
175 | }
176 |
177 | return val;
178 | }
179 |
180 | assignRequestData() {
181 | const data = {};
182 | if (this.options.data) {
183 | Object.assign(data, this.options.data);
184 | }
185 |
186 | const attr = this.ogElement.getAttribute('data-request-data');
187 | if (attr) {
188 | Object.assign(data, JsonParser.paramToObj('data-request-data', attr));
189 | }
190 |
191 | elementParents(this.ogElement, '[data-request-data]').reverse().forEach(function(el) {
192 | Object.assign(data, JsonParser.paramToObj(
193 | 'data-request-data',
194 | el.getAttribute('data-request-data')
195 | ));
196 | });
197 |
198 | this.options.data = data;
199 | }
200 | }
201 |
202 | function elementParents(element, selector) {
203 | const parents = [];
204 | if (!element.parentNode) {
205 | return parents;
206 | }
207 |
208 | let ancestor = element.parentNode.closest(selector);
209 | while (ancestor) {
210 | parents.push(ancestor);
211 | ancestor = ancestor.parentNode.closest(selector);
212 | }
213 |
214 | return parents;
215 | }
216 |
217 | function normalizeDataKey(key) {
218 | return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`)
219 | }
220 |
--------------------------------------------------------------------------------
/src/extras/attach-loader.js:
--------------------------------------------------------------------------------
1 | import { unindent } from "../util";
2 |
3 | export class AttachLoader
4 | {
5 | static stylesheetReady = false;
6 |
7 | constructor() {
8 | this.stylesheetElement = this.createStylesheetElement();
9 | }
10 |
11 | static get defaultCSS() {
12 | return unindent `
13 | .oc-attach-loader:after {
14 | content: '';
15 | display: inline-block;
16 | vertical-align: middle;
17 | margin-left: .4em;
18 | height: 1em;
19 | width: 1em;
20 | animation: oc-rotate-loader 0.8s infinite linear;
21 | border: .2em solid currentColor;
22 | border-right-color: transparent;
23 | border-radius: 50%;
24 | opacity: .5;
25 | }
26 | @keyframes oc-rotate-loader {
27 | 0% { transform: rotate(0deg); }
28 | 100% { transform: rotate(360deg); }
29 | }
30 | `;
31 | }
32 |
33 | static get attachLoader() {
34 | return {
35 | show: function(el) {
36 | (new AttachLoader).show(resolveElement(el));
37 | },
38 | hide: function(el) {
39 | (new AttachLoader).hide(resolveElement(el));
40 | },
41 | hideAll: function() {
42 | (new AttachLoader).hideAll();
43 | }
44 | };
45 | }
46 |
47 | // Public
48 | show(el) {
49 | this.installStylesheetElement();
50 |
51 | if (isElementInput(el)) {
52 | const loadEl = document.createElement('span');
53 | loadEl.className = 'oc-attach-loader is-inline';
54 | el.parentNode.insertBefore(loadEl, el.nextSibling); // insertAfter
55 | }
56 | else {
57 | el.classList.add('oc-attach-loader');
58 | el.disabled = true;
59 | }
60 | }
61 |
62 | hide(el) {
63 | if (isElementInput(el)) {
64 | if (el.nextElementSibling && el.nextElementSibling.classList.contains('oc-attach-loader')) {
65 | el.nextElementSibling.remove();
66 | }
67 | }
68 | else {
69 | el.classList.remove('oc-attach-loader');
70 | el.disabled = false;
71 | }
72 | }
73 |
74 | hideAll() {
75 | document.querySelectorAll('.oc-attach-loader.is-inline').forEach((el) => {
76 | el.remove();
77 | });
78 |
79 | document.querySelectorAll('.oc-attach-loader').forEach((el) => {
80 | el.classList.remove('oc-attach-loader');
81 | el.disabled = false;
82 | });
83 | }
84 |
85 | showForm(el) {
86 | if (el.dataset.attachLoading !== undefined) {
87 | this.show(el);
88 | }
89 |
90 | if (el.matches('form')) {
91 | var self = this;
92 | el.querySelectorAll('[data-attach-loading][type=submit]').forEach(function(otherEl) {
93 | if (!isElementInput(otherEl)) {
94 | self.show(otherEl);
95 | }
96 | });
97 | }
98 | }
99 |
100 | hideForm(el) {
101 | if (el.dataset.attachLoading !== undefined) {
102 | this.hide(el);
103 | }
104 |
105 | if (el.matches('form')) {
106 | var self = this;
107 | el.querySelectorAll('[data-attach-loading]').forEach(function(otherEl) {
108 | if (!isElementInput(otherEl)) {
109 | self.hide(otherEl);
110 | }
111 | });
112 | }
113 | }
114 |
115 | // Private
116 | installStylesheetElement() {
117 | if (!AttachLoader.stylesheetReady) {
118 | document.head.insertBefore(this.stylesheetElement, document.head.firstChild);
119 | AttachLoader.stylesheetReady = true;
120 | }
121 | }
122 |
123 | createStylesheetElement() {
124 | const element = document.createElement('style');
125 | element.textContent = AttachLoader.defaultCSS;
126 | return element;
127 | }
128 | }
129 |
130 | function isElementInput(el) {
131 | return ['input', 'select', 'textarea'].includes((el.tagName || '').toLowerCase());
132 | }
133 |
134 | function resolveElement(el) {
135 | if (typeof el === 'string') {
136 | el = document.querySelector(el);
137 | }
138 |
139 | if (!el) {
140 | throw new Error("Invalid element for attach loader.");
141 | }
142 |
143 | return el;
144 | }
145 |
--------------------------------------------------------------------------------
/src/extras/controller.js:
--------------------------------------------------------------------------------
1 | import { Validator } from "./validator";
2 | import { AttachLoader } from "./attach-loader";
3 | import { FlashMessage } from "./flash-message";
4 | import { Events } from "../util/events";
5 | import { getReferrerUrl } from "../util/referrer";
6 |
7 | export class Controller
8 | {
9 | constructor() {
10 | this.started = false;
11 |
12 | // Progress bar default value
13 | this.enableProgressBar = function(event) {
14 | const { options } = event.detail.context;
15 | if (options.progressBar === null) {
16 | options.progressBar = true;
17 | }
18 | }
19 |
20 | // Attach loader
21 | this.showAttachLoader = (event) => {
22 | this.attachLoader.showForm(event.target);
23 | };
24 |
25 | this.hideAttachLoader = (event) => {
26 | this.attachLoader.hideForm(event.target);
27 | };
28 |
29 | this.hideAllAttachLoaders = (event) => {
30 | this.attachLoader.hideAll();
31 | };
32 |
33 | // Validator
34 | this.validatorSubmit = (event) => {
35 | this.validator.submit(event.target);
36 | };
37 |
38 | this.validatorValidate = (event) => {
39 | this.validator.validate(
40 | event.target,
41 | event.detail.fields,
42 | event.detail.message,
43 | shouldShowFlashMessage(event.detail.context.options.flash, 'validate')
44 | );
45 | };
46 |
47 | // Flash message
48 | this.flashMessageBind = (event) => {
49 | const { options } = event.detail.context;
50 | if (options.flash) {
51 | options.handleErrorMessage = (message) => {
52 | if (
53 | message &&
54 | shouldShowFlashMessage(options.flash, 'error') ||
55 | shouldShowFlashMessage(options.flash, 'validate')
56 | ) {
57 | this.flashMessage.show({ message, type: 'error' });
58 | }
59 | }
60 |
61 | options.handleFlashMessage = (message, type) => {
62 | if (message && shouldShowFlashMessage(options.flash, type)) {
63 | this.flashMessage.show({ message, type });
64 | }
65 | }
66 | }
67 |
68 | var context = event.detail;
69 | options.handleProgressMessage = (message, isDone) => {
70 | if (!isDone) {
71 | context.progressMessageId = this.flashMessage.show({ message, type: 'loading', interval: 10 });
72 | }
73 | else {
74 | this.flashMessage.show(context.progressMessageId
75 | ? { replace: context.progressMessageId }
76 | : { hideAll: true });
77 |
78 | context = null;
79 | }
80 | }
81 | };
82 |
83 | this.flashMessageRender = (event) => {
84 | this.flashMessage.render();
85 | };
86 |
87 | this.hideAllFlashMessages = (event) => {
88 | this.flashMessage.hideAll();
89 | };
90 |
91 | // Browser redirect
92 | this.handleBrowserRedirect = function(event) {
93 | if (event.defaultPrevented) {
94 | return;
95 | }
96 |
97 | const href = getReferrerUrl();
98 | if (!href) {
99 | return;
100 | }
101 |
102 | event.preventDefault();
103 | if (oc.useTurbo && oc.useTurbo()) {
104 | oc.visit(href);
105 | }
106 | else {
107 | location.assign(href);
108 | }
109 | };
110 | }
111 |
112 | start() {
113 | if (!this.started) {
114 | // Progress bar
115 | addEventListener('ajax:setup', this.enableProgressBar);
116 |
117 | // Attach loader
118 | this.attachLoader = new AttachLoader;
119 | Events.on(document, 'ajax:promise', 'form, [data-attach-loading]', this.showAttachLoader);
120 | Events.on(document, 'ajax:fail', 'form, [data-attach-loading]', this.hideAttachLoader);
121 | Events.on(document, 'ajax:done', 'form, [data-attach-loading]', this.hideAttachLoader);
122 | addEventListener('page:before-cache', this.hideAllAttachLoaders);
123 |
124 | // Validator
125 | this.validator = new Validator;
126 | Events.on(document, 'ajax:before-validate', '[data-request-validate]', this.validatorValidate);
127 | Events.on(document, 'ajax:promise', '[data-request-validate]', this.validatorSubmit);
128 |
129 | // Flash message
130 | this.flashMessage = new FlashMessage;
131 | addEventListener('render', this.flashMessageRender);
132 | addEventListener('ajax:setup', this.flashMessageBind);
133 | addEventListener('page:before-cache', this.hideAllFlashMessages);
134 |
135 | // Browser redirect
136 | Events.on(document, 'click', '[data-browser-redirect-back]', this.handleBrowserRedirect);
137 |
138 | this.started = true;
139 | }
140 | }
141 |
142 | stop() {
143 | if (this.started) {
144 | // Progress bar
145 | removeEventListener('ajax:setup', this.enableProgressBar);
146 |
147 | // Attach loader
148 | this.attachLoader = null;
149 | Events.off(document, 'ajax:promise', 'form, [data-attach-loading]', this.showAttachLoader);
150 | Events.off(document, 'ajax:fail', 'form, [data-attach-loading]', this.hideAttachLoader);
151 | Events.off(document, 'ajax:done', 'form, [data-attach-loading]', this.hideAttachLoader);
152 | removeEventListener('page:before-cache', this.hideAllAttachLoaders);
153 |
154 | // Validator
155 | this.validator = null;
156 | Events.off(document, 'ajax:before-validate', '[data-request-validate]', this.validatorValidate);
157 | Events.off(document, 'ajax:promise', '[data-request-validate]', this.validatorSubmit);
158 |
159 | // Flash message
160 | this.flashMessage = null;
161 | removeEventListener('render', this.flashMessageRender);
162 | removeEventListener('ajax:setup', this.flashMessageBind);
163 | removeEventListener('page:before-cache', this.hideAllFlashMessages);
164 |
165 | // Browser redirect
166 | Events.off(document, 'click', '[data-browser-redirect-back]', this.handleBrowserRedirect);
167 |
168 | this.started = false;
169 | }
170 | }
171 | }
172 |
173 | function shouldShowFlashMessage(value, type) {
174 | // Validation messages are not included by default
175 | if (value === true && type !== 'validate') {
176 | return true;
177 | }
178 |
179 | if (typeof value !== 'string') {
180 | return false;
181 | }
182 |
183 | if (value === '*') {
184 | return true;
185 | }
186 |
187 | let result = false;
188 | value.split(',').forEach(function(validType) {
189 | if (validType.trim() === type) {
190 | result = true;
191 | }
192 | });
193 |
194 | return result;
195 | }
196 |
--------------------------------------------------------------------------------
/src/extras/flash-message.js:
--------------------------------------------------------------------------------
1 | import { unindent } from "../util";
2 |
3 | export class FlashMessage
4 | {
5 | static instance = null;
6 | static stylesheetReady = false;
7 |
8 | constructor() {
9 | this.queue = [];
10 | this.lastUniqueId = 0;
11 | this.displayedMessage = null;
12 | this.stylesheetElement = this.createStylesheetElement();
13 | }
14 |
15 | static get defaultCSS() {
16 | return unindent `
17 | .oc-flash-message {
18 | display: flex;
19 | position: fixed;
20 | z-index: 10300;
21 | width: 500px;
22 | left: 50%;
23 | top: 50px;
24 | margin-left: -250px;
25 | color: #fff;
26 | font-size: 1rem;
27 | padding: 10px 15px;
28 | border-radius: 5px;
29 | opacity: 0;
30 | transition: all 0.5s, width 0s;
31 | transform: scale(0.9);
32 | }
33 | @media (max-width: 768px) {
34 | .oc-flash-message {
35 | left: 1rem;
36 | right: 1rem;
37 | top: 1rem;
38 | margin-left: 0;
39 | width: auto;
40 | }
41 | }
42 | .oc-flash-message.flash-show {
43 | opacity: 1;
44 | transform: scale(1);
45 | }
46 | .oc-flash-message.loading {
47 | transition: opacity 0.2s;
48 | transform: scale(1);
49 | }
50 | .oc-flash-message.success {
51 | background: #86cb43;
52 | }
53 | .oc-flash-message.error {
54 | background: #cc3300;
55 | }
56 | .oc-flash-message.warning {
57 | background: #f0ad4e;
58 | }
59 | .oc-flash-message.info, .oc-flash-message.loading {
60 | background: #5fb6f5;
61 | }
62 | .oc-flash-message span.flash-message {
63 | flex-grow: 1;
64 | }
65 | .oc-flash-message a.flash-close {
66 | box-sizing: content-box;
67 | width: 1em;
68 | height: 1em;
69 | padding: .25em .25em;
70 | background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23FFF'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
71 | border: 0;
72 | border-radius: .25rem;
73 | opacity: .5;
74 | text-decoration: none;
75 | cursor: pointer;
76 | }
77 | .oc-flash-message a.flash-close:hover,
78 | .oc-flash-message a.flash-close:focus {
79 | opacity: 1;
80 | }
81 | .oc-flash-message.loading a.flash-close {
82 | display: none;
83 | }
84 | .oc-flash-message span.flash-loader {
85 | margin-right: 1em;
86 | }
87 | .oc-flash-message span.flash-loader:after {
88 | position: relative;
89 | top: 2px;
90 | content: '';
91 | display: inline-block;
92 | height: 1.2em;
93 | width: 1.2em;
94 | animation: oc-flash-loader 0.8s infinite linear;
95 | border: .2em solid currentColor;
96 | border-right-color: transparent;
97 | border-radius: 50%;
98 | opacity: .5;
99 | }
100 | html[data-turbo-preview] .oc-flash-message {
101 | opacity: 0;
102 | }
103 | @keyframes oc-flash-loader {
104 | 0% { transform: rotate(0deg); }
105 | 100% { transform: rotate(360deg); }
106 | }
107 | `;
108 | }
109 |
110 | static flashMsg(options) {
111 | return getOrCreateInstance().show(options);
112 | }
113 |
114 | runQueue() {
115 | if (this.displayedMessage) {
116 | return;
117 | }
118 |
119 | var options = this.queue.shift();
120 | if (options === undefined) {
121 | return;
122 | }
123 |
124 | this.buildFlashMessage(options);
125 | }
126 |
127 | clearQueue() {
128 | this.queue = [];
129 |
130 | if (this.displayedMessage && this.displayedMessage.uniqueId) {
131 | this.hide(this.displayedMessage.uniqueId, true);
132 | }
133 | }
134 |
135 | removeFromQueue(uniqueId) {
136 | for (var index = 0; index < this.queue.length; index++) {
137 | if (this.queue[index].uniqueId == uniqueId) {
138 | this.queue.splice(index, 1);
139 | return;
140 | }
141 | }
142 | }
143 |
144 | show(options = {}) {
145 | this.installStylesheetElement();
146 |
147 | let {
148 | message = '',
149 | type = 'info',
150 | replace = null,
151 | hideAll = false
152 | } = options;
153 |
154 | // Legacy API
155 | if (options.text) message = options.text;
156 | if (options.class) type = options.class;
157 |
158 | // Clear all messages
159 | if (hideAll || type === 'error' || type === 'loading') {
160 | this.clearQueue();
161 | }
162 |
163 | // Replace or remove a message
164 | if (replace) {
165 | if (this.displayedMessage && replace === this.displayedMessage.uniqueId) {
166 | this.hide(replace, true);
167 | }
168 | else {
169 | this.removeFromQueue(replace);
170 | }
171 | }
172 |
173 | // Nothing to show
174 | if (!message) {
175 | return;
176 | }
177 |
178 | var uniqueId = this.makeUniqueId();
179 |
180 | this.queue.push({
181 | ...options,
182 | uniqueId: uniqueId
183 | });
184 |
185 | this.runQueue();
186 |
187 | return uniqueId;
188 | }
189 |
190 | makeUniqueId() {
191 | return ++this.lastUniqueId;
192 | }
193 |
194 | buildFlashMessage(options = {}) {
195 | let {
196 | message = '',
197 | type = 'info',
198 | target = null,
199 | interval = 3
200 | } = options;
201 |
202 | // Legacy API
203 | if (options.text) message = options.text;
204 | if (options.class) type = options.class;
205 |
206 | // Idempotence
207 | if (target) {
208 | target.removeAttribute('data-control');
209 | }
210 |
211 | // Inject element
212 | var flashElement = this.createFlashElement(message, type);
213 | this.createMessagesElement().appendChild(flashElement);
214 |
215 | this.displayedMessage = {
216 | uniqueId: options.uniqueId,
217 | element: flashElement,
218 | options
219 | };
220 |
221 | // Remove logic
222 | var remove = (event) => {
223 | clearInterval(timer);
224 | flashElement.removeEventListener('click', pause);
225 | flashElement.removeEventListener('extras:flash-remove', remove);
226 | flashElement.querySelector('.flash-close').removeEventListener('click', remove);
227 | flashElement.classList.remove('flash-show');
228 |
229 | if (event && event.detail.isReplace) {
230 | flashElement.remove();
231 | this.displayedMessage = null;
232 | this.runQueue();
233 | }
234 | else {
235 | setTimeout(() => {
236 | flashElement.remove();
237 | this.displayedMessage = null;
238 | this.runQueue();
239 | }, 600);
240 | }
241 | };
242 |
243 | // Pause logic
244 | var pause = () => {
245 | clearInterval(timer);
246 | };
247 |
248 | // Events
249 | flashElement.addEventListener('click', pause, { once: true });
250 | flashElement.addEventListener('extras:flash-remove', remove, { once: true });
251 | flashElement.querySelector('.flash-close').addEventListener('click', remove, { once: true });
252 |
253 | // Timeout
254 | var timer;
255 | if (interval && interval !== 0) {
256 | timer = setTimeout(remove, interval * 1000);
257 | }
258 |
259 | setTimeout(() => {
260 | flashElement.classList.add('flash-show');
261 | }, 20);
262 | }
263 |
264 | render() {
265 | document.querySelectorAll('[data-control=flash-message]').forEach((el) => {
266 | this.show({ ...el.dataset, target: el, message: el.innerHTML });
267 | el.remove();
268 | });
269 | }
270 |
271 | hide(uniqueId, isReplace) {
272 | if (this.displayedMessage && uniqueId === this.displayedMessage.uniqueId) {
273 | this.displayedMessage.element.dispatchEvent(new CustomEvent('extras:flash-remove', {
274 | detail: { isReplace }
275 | }));
276 | }
277 | }
278 |
279 | hideAll() {
280 | this.clearQueue();
281 | this.displayedMessage = null;
282 |
283 | document.querySelectorAll('.oc-flash-message, [data-control=flash-message]').forEach((el) => {
284 | el.remove();
285 | });
286 | }
287 |
288 | createFlashElement(message, type) {
289 | const element = document.createElement('div');
290 | const loadingHtml = type === 'loading' ? '' : '';
291 | const closeHtml = '';
292 | element.className = 'oc-flash-message ' + type;
293 | element.innerHTML = loadingHtml + '' + message + '' + closeHtml;
294 | return element;
295 | }
296 |
297 | // Private
298 | installStylesheetElement() {
299 | if (!FlashMessage.stylesheetReady) {
300 | document.head.insertBefore(this.stylesheetElement, document.head.firstChild);
301 | FlashMessage.stylesheetReady = true;
302 | }
303 | }
304 |
305 | createStylesheetElement() {
306 | const element = document.createElement('style');
307 | element.textContent = FlashMessage.defaultCSS;
308 | return element;
309 | }
310 |
311 | createMessagesElement() {
312 | const found = document.querySelector('.oc-flash-messages')
313 | if (found) {
314 | return found;
315 | }
316 |
317 | const element = document.createElement('div');
318 | element.className = 'oc-flash-messages';
319 | document.body.appendChild(element);
320 | return element;
321 | }
322 | }
323 |
324 | function getOrCreateInstance() {
325 | if (!FlashMessage.instance) {
326 | FlashMessage.instance = new FlashMessage;
327 | }
328 |
329 | return FlashMessage.instance;
330 | }
331 |
--------------------------------------------------------------------------------
/src/extras/index.js:
--------------------------------------------------------------------------------
1 | import namespace from "./namespace";
2 | export default namespace;
3 |
4 | if (!window.oc) {
5 | window.oc = {};
6 | }
7 |
8 | if (!window.oc.AjaxExtras) {
9 | // Namespace
10 | window.oc.AjaxExtras = namespace;
11 |
12 | // Flash messages
13 | window.oc.flashMsg = namespace.flashMsg;
14 |
15 | // Progress bar
16 | window.oc.progressBar = namespace.progressBar;
17 |
18 | // Attach loader
19 | window.oc.attachLoader = namespace.attachLoader;
20 |
21 | // Boot controller
22 | if (!isAMD() && !isCommonJS()) {
23 | namespace.start();
24 | }
25 | }
26 |
27 | function isAMD() {
28 | return typeof define == "function" && define.amd;
29 | }
30 |
31 | function isCommonJS() {
32 | return typeof exports == "object" && typeof module != "undefined";
33 | }
34 |
--------------------------------------------------------------------------------
/src/extras/migrate.js:
--------------------------------------------------------------------------------
1 |
2 | export class Migrate
3 | {
4 | bind() {
5 | if ($.oc === undefined) {
6 | $.oc = {};
7 | }
8 |
9 | $.oc.flashMsg = window.oc.flashMsg;
10 | $.oc.stripeLoadIndicator = window.oc.progressBar;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/extras/namespace.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "./controller";
2 | import { Migrate } from "./migrate";
3 | import { FlashMessage } from "./flash-message";
4 | import { ProgressBar } from "./progress-bar";
5 | import { AttachLoader } from "./attach-loader";
6 | const controller = new Controller;
7 |
8 | export default {
9 | controller,
10 |
11 | flashMsg: FlashMessage.flashMsg,
12 |
13 | progressBar: ProgressBar.progressBar,
14 |
15 | attachLoader: AttachLoader.attachLoader,
16 |
17 | start() {
18 | controller.start();
19 |
20 | if (window.jQuery) {
21 | (new Migrate).bind();
22 | }
23 | },
24 |
25 | stop() {
26 | controller.stop();
27 | }
28 | };
29 |
--------------------------------------------------------------------------------
/src/extras/progress-bar.js:
--------------------------------------------------------------------------------
1 | import { unindent } from "../util";
2 |
3 | export class ProgressBar
4 | {
5 | static instance = null;
6 | static stylesheetReady = false;
7 | static animationDuration = 300;
8 |
9 | constructor() {
10 | this.stylesheetElement = this.createStylesheetElement();
11 | this.progressElement = this.createProgressElement();
12 | this.hiding = false;
13 | this.value = 0;
14 | this.visible = false;
15 | this.trickle = () => {
16 | this.setValue(this.value + Math.random() / 100);
17 | };
18 | }
19 |
20 | static get defaultCSS() {
21 | return unindent `
22 | .oc-progress-bar {
23 | position: fixed;
24 | display: block;
25 | top: 0;
26 | left: 0;
27 | height: 3px;
28 | background: #0076ff;
29 | z-index: 9999;
30 | transition:
31 | width ${ProgressBar.animationDuration}ms ease-out,
32 | opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in;
33 | transform: translate3d(0, 0, 0);
34 | }
35 | `;
36 | }
37 |
38 | static get progressBar() {
39 | return {
40 | show: function() {
41 | const instance = getOrCreateInstance();
42 | instance.setValue(0);
43 | instance.show();
44 | },
45 | hide: function() {
46 | const instance = getOrCreateInstance();
47 | instance.setValue(100);
48 | instance.hide();
49 | }
50 | };
51 | }
52 |
53 | show(options = {}) {
54 | if (options.cssClass) {
55 | this.progressElement.classList.add(options.cssClass);
56 | }
57 |
58 | if (!this.visible) {
59 | this.visible = true;
60 | this.installStylesheetElement();
61 | this.installProgressElement();
62 | this.startTrickling();
63 | }
64 | }
65 |
66 | hide() {
67 | if (this.visible && !this.hiding) {
68 | this.hiding = true;
69 | this.fadeProgressElement(() => {
70 | this.uninstallProgressElement();
71 | this.stopTrickling();
72 | this.visible = false;
73 | this.hiding = false;
74 | });
75 | }
76 | }
77 |
78 | setValue(value) {
79 | this.value = value;
80 | this.refresh();
81 | }
82 |
83 | // Private
84 | installStylesheetElement() {
85 | if (!ProgressBar.stylesheetReady) {
86 | document.head.insertBefore(this.stylesheetElement, document.head.firstChild);
87 | ProgressBar.stylesheetReady = true;
88 | }
89 | }
90 |
91 | installProgressElement() {
92 | this.progressElement.style.width = "0";
93 | this.progressElement.style.opacity = "1";
94 | document.documentElement.insertBefore(this.progressElement, document.body);
95 | this.refresh();
96 | }
97 |
98 | fadeProgressElement(callback) {
99 | this.progressElement.style.opacity = "0";
100 | setTimeout(callback, ProgressBar.animationDuration * 1.5);
101 | }
102 |
103 | uninstallProgressElement() {
104 | if (this.progressElement.parentNode) {
105 | document.documentElement.removeChild(this.progressElement);
106 | }
107 | }
108 |
109 | startTrickling() {
110 | if (!this.trickleInterval) {
111 | this.trickleInterval = setInterval(this.trickle, ProgressBar.animationDuration);
112 | }
113 | }
114 |
115 | stopTrickling() {
116 | clearInterval(this.trickleInterval);
117 | delete this.trickleInterval;
118 | }
119 |
120 | refresh() {
121 | requestAnimationFrame(() => {
122 | this.progressElement.style.width = `${10 + (this.value * 90)}%`;
123 | });
124 | }
125 |
126 | createStylesheetElement() {
127 | const element = document.createElement('style');
128 | element.textContent = ProgressBar.defaultCSS;
129 | return element;
130 | }
131 |
132 | createProgressElement() {
133 | const element = document.createElement('div');
134 | element.className = 'oc-progress-bar';
135 | return element;
136 | }
137 | }
138 |
139 | function getOrCreateInstance() {
140 | if (!ProgressBar.instance) {
141 | ProgressBar.instance = new ProgressBar;
142 | }
143 |
144 | return ProgressBar.instance;
145 | }
146 |
--------------------------------------------------------------------------------
/src/extras/validator.js:
--------------------------------------------------------------------------------
1 | import { Events } from "../util/events";
2 |
3 | export class Validator
4 | {
5 | submit(el) {
6 | var form = el.closest('form');
7 | if (!form) {
8 | return;
9 | }
10 |
11 | form.querySelectorAll('[data-validate-for]').forEach(function(el) {
12 | el.classList.remove('oc-visible');
13 | });
14 |
15 | form.querySelectorAll('[data-validate-error]').forEach(function(el) {
16 | el.classList.remove('oc-visible');
17 | });
18 | }
19 |
20 | validate(el, fields, errorMsg, allowDefault) {
21 | var form = el.closest('form'),
22 | messages = [];
23 |
24 | if (!form) {
25 | return;
26 | }
27 |
28 | for (var fieldName in fields) {
29 | // Build messages
30 | var fieldMessages = fields[fieldName];
31 | messages = [...messages, ...fieldMessages];
32 |
33 | // Display message next to field
34 | var field = form.querySelector('[data-validate-for="'+fieldName+'"]');
35 | if (field) {
36 | if (!field.innerHTML || field.dataset.emptyMode) {
37 | field.dataset.emptyMode = true;
38 | field.innerHTML = fieldMessages.join(', ');
39 | }
40 | field.classList.add('oc-visible');
41 | }
42 | }
43 |
44 | var container = form.querySelector('[data-validate-error]');
45 | if (container) {
46 | container.classList.add('oc-visible');
47 |
48 | // Messages found inside the container
49 | var oldMessages = container.querySelectorAll('[data-message]');
50 | if (oldMessages.length > 0) {
51 | var clone = oldMessages[0];
52 | messages.forEach(function(message) {
53 | var newNode = clone.cloneNode(true);
54 | newNode.innerHTML = message;
55 | // Insert after
56 | clone.parentNode.insertBefore(newNode, clone.nextSibling);
57 | });
58 |
59 | oldMessages.forEach(function(el) {
60 | el.remove();
61 | });
62 | }
63 | // Just use the container to set the value
64 | else {
65 | container.innerHTML = errorMsg;
66 | }
67 | }
68 |
69 | // Flash messages want a pass here
70 | if (allowDefault) {
71 | return;
72 | }
73 |
74 | // Prevent default error behavior
75 | Events.one(form, 'ajax:request-error', function(event) {
76 | event.preventDefault();
77 | });
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/framework-bundle.js:
--------------------------------------------------------------------------------
1 | /**
2 | * --------------------------------------------------------------------------
3 | * October CMS: Frontend JavaScript Framework
4 | * https://octobercms.com
5 | * --------------------------------------------------------------------------
6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges
7 | * --------------------------------------------------------------------------
8 | */
9 |
10 | import "./request";
11 | import "./core";
12 | import "./extras";
13 | import "./observe";
14 | import "./turbo";
15 |
--------------------------------------------------------------------------------
/src/framework-extras.js:
--------------------------------------------------------------------------------
1 | /**
2 | * --------------------------------------------------------------------------
3 | * October CMS: Frontend JavaScript Framework
4 | * https://octobercms.com
5 | * --------------------------------------------------------------------------
6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges
7 | * --------------------------------------------------------------------------
8 | */
9 |
10 | import "./request";
11 | import "./core";
12 | import "./extras";
13 | import "./observe";
14 |
--------------------------------------------------------------------------------
/src/framework-turbo.js:
--------------------------------------------------------------------------------
1 | /**
2 | * --------------------------------------------------------------------------
3 | * October CMS: Frontend JavaScript Framework
4 | * https://octobercms.com
5 | * --------------------------------------------------------------------------
6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges
7 | * --------------------------------------------------------------------------
8 | */
9 |
10 | import "./request";
11 | import "./core";
12 | import "./turbo";
13 |
--------------------------------------------------------------------------------
/src/framework.js:
--------------------------------------------------------------------------------
1 | /**
2 | * --------------------------------------------------------------------------
3 | * October CMS: Frontend JavaScript Framework
4 | * https://octobercms.com
5 | * --------------------------------------------------------------------------
6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges
7 | * --------------------------------------------------------------------------
8 | */
9 |
10 | import "./request";
11 | import "./core";
12 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * --------------------------------------------------------------------------
3 | * October CMS: Frontend JavaScript Framework
4 | * https://octobercms.com
5 | * --------------------------------------------------------------------------
6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges
7 | * --------------------------------------------------------------------------
8 | */
9 |
10 | import AjaxFramework from "./core/namespace";
11 | import AjaxRequest from "./request/namespace";
12 | import AjaxExtras from "./extras/namespace";
13 | import AjaxObserve from "./observe/namespace";
14 | import AjaxTurbo from "./turbo/namespace";
15 | import { ControlBase } from "./observe/control-base";
16 | import { AssetManager } from "./request/asset-manager";
17 | import { Events } from "./util/events";
18 | import { waitFor } from "./util/wait";
19 |
20 | export default {
21 | AjaxFramework,
22 | AjaxRequest,
23 | AjaxExtras,
24 | AjaxObserve,
25 | AjaxTurbo,
26 | ControlBase,
27 | AssetManager,
28 | Events,
29 | waitFor,
30 | ajax: AjaxRequest.send,
31 | request: AjaxFramework.requestElement,
32 | parseJSON: AjaxFramework.parseJSON,
33 | serializeJSON: AjaxFramework.serializeJSON,
34 | flashMsg: AjaxExtras.flashMsg,
35 | progressBar: AjaxExtras.progressBar,
36 | attachLoader: AjaxExtras.attachLoader,
37 | useTurbo: AjaxTurbo.isEnabled,
38 | pageReady: AjaxTurbo.pageReady,
39 | visit: AjaxTurbo.visit,
40 | registerControl: AjaxObserve.registerControl,
41 | importControl: AjaxObserve.importControl,
42 | observeControl: AjaxObserve.observeControl,
43 | fetchControl: AjaxObserve.fetchControl,
44 | fetchControls: AjaxObserve.fetchControls
45 | };
46 |
--------------------------------------------------------------------------------
/src/observe/application.js:
--------------------------------------------------------------------------------
1 | import { Dispatcher } from "./dispatcher";
2 | import { Container } from "./container";
3 | import { domReady } from "../util/wait";
4 |
5 | export class Application
6 | {
7 | constructor() {
8 | this.started = false;
9 | this.element = document.documentElement;
10 | this.dispatcher = new Dispatcher(this);
11 | this.container = new Container(this);
12 | }
13 |
14 | startAsync() {
15 | domReady().then(() => {
16 | this.start();
17 | });
18 | }
19 |
20 | start() {
21 | if (!this.started) {
22 | this.started = true;
23 | this.dispatcher.start();
24 | this.container.start();
25 | }
26 | }
27 |
28 | stop() {
29 | if (this.started) {
30 | this.dispatcher.stop();
31 | this.container.stop();
32 | this.started = false;
33 | }
34 | }
35 |
36 | register(identifier, controlConstructor) {
37 | this.load({ identifier, controlConstructor });
38 | }
39 |
40 | observe(element, identifier) {
41 | const observer = this.container.scopeObserver;
42 | observer.elementMatchedValue(element, observer.parseValueForToken({
43 | element,
44 | content: identifier
45 | }));
46 |
47 | const foundControl = this.getControlForElementAndIdentifier(element, identifier);
48 | if (!element.matches(`[data-control~="${identifier}"]`)) {
49 | element.dataset.control = ((element.dataset.control || '') + ' ' + identifier).trim();
50 | }
51 | return foundControl;
52 | }
53 |
54 | import(identifier) {
55 | const module = this.container.getModuleForIdentifier(identifier);
56 | if (!module) {
57 | throw new Error(`Control is not registered [${identifier}]`);
58 | }
59 |
60 | return module.controlConstructor;
61 | }
62 |
63 | fetch(element, identifier) {
64 | if (typeof element === 'string') {
65 | element = document.querySelector(element);
66 | }
67 |
68 | if (!identifier) {
69 | identifier = element.dataset.control;
70 | }
71 |
72 | return element
73 | ? this.getControlForElementAndIdentifier(element, identifier)
74 | : null;
75 | }
76 |
77 | fetchAll(elements, identifier) {
78 | if (typeof elements === 'string') {
79 | elements = document.querySelectorAll(elements);
80 | }
81 |
82 | const result = [];
83 | elements.forEach((element) => {
84 | const control = this.fetch(element, identifier);
85 | if (control) {
86 | result.push(control);
87 | }
88 | });
89 | return result;
90 | }
91 |
92 | load(head, ...rest) {
93 | const definitions = Array.isArray(head) ? head : [head, ...rest];
94 | definitions.forEach((definition) => {
95 | if (definition.controlConstructor.shouldLoad) {
96 | this.container.loadDefinition(definition);
97 | }
98 | });
99 | }
100 |
101 | unload(head, ...rest) {
102 | const identifiers = Array.isArray(head) ? head : [head, ...rest];
103 | identifiers.forEach((identifier) => this.container.unloadIdentifier(identifier));
104 | }
105 |
106 | // Controls
107 | get controls() {
108 | return this.container.contexts.map((context) => context.control);
109 | }
110 |
111 | getControlForElementAndIdentifier(element, identifier) {
112 | const context = this.container.getContextForElementAndIdentifier(element, identifier);
113 | return context ? context.control : null;
114 | }
115 |
116 | // Error handling
117 | handleError(error, message, detail) {
118 | var _a;
119 | console.error(`%s\n\n%o\n\n%o`, message, error, detail);
120 | (_a = window.onerror) === null || _a === void 0 ? void 0 : _a.call(window, message, "", 0, 0, error);
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/src/observe/container.js:
--------------------------------------------------------------------------------
1 | import { Module } from "./module";
2 | import { Scope } from "./scope";
3 | import { ScopeObserver } from "./scope-observer";
4 | import { Multimap } from "./util/multimap";
5 |
6 | export class Container
7 | {
8 | constructor(application) {
9 | this.application = application;
10 | this.scopeObserver = new ScopeObserver(this.element, this);
11 | this.scopesByIdentifier = new Multimap();
12 | this.modulesByIdentifier = new Map();
13 | }
14 |
15 | get element() {
16 | return this.application.element;
17 | }
18 |
19 | get modules() {
20 | return Array.from(this.modulesByIdentifier.values());
21 | }
22 |
23 | get contexts() {
24 | return this.modules.reduce((contexts, module) => contexts.concat(module.contexts), []);
25 | }
26 |
27 | start() {
28 | this.scopeObserver.start();
29 | }
30 |
31 | stop() {
32 | this.scopeObserver.stop();
33 | }
34 |
35 | loadDefinition(definition) {
36 | this.unloadIdentifier(definition.identifier);
37 | const module = new Module(this.application, definition);
38 | this.connectModule(module);
39 | const afterLoad = definition.controlConstructor.afterLoad;
40 | if (afterLoad) {
41 | afterLoad.call(definition.controlConstructor, definition.identifier, this.application);
42 | }
43 | }
44 |
45 | unloadIdentifier(identifier) {
46 | const module = this.modulesByIdentifier.get(identifier);
47 | if (module) {
48 | this.disconnectModule(module);
49 | }
50 | }
51 |
52 | getModuleForIdentifier(identifier) {
53 | return this.modulesByIdentifier.get(identifier);
54 | }
55 |
56 | getContextForElementAndIdentifier(element, identifier) {
57 | const module = this.modulesByIdentifier.get(identifier);
58 | if (module) {
59 | return module.contexts.find((context) => context.element == element);
60 | }
61 | }
62 |
63 | // Error handler delegate
64 | handleError(error, message, detail) {
65 | this.application.handleError(error, message, detail);
66 | }
67 |
68 | // Scope observer delegate
69 | createScopeForElementAndIdentifier(element, identifier) {
70 | return new Scope(element, identifier);
71 | }
72 |
73 | scopeConnected(scope) {
74 | this.scopesByIdentifier.add(scope.identifier, scope);
75 | const module = this.modulesByIdentifier.get(scope.identifier);
76 | if (module) {
77 | module.connectContextForScope(scope);
78 | }
79 | }
80 |
81 | scopeDisconnected(scope) {
82 | this.scopesByIdentifier.delete(scope.identifier, scope);
83 | const module = this.modulesByIdentifier.get(scope.identifier);
84 | if (module) {
85 | module.disconnectContextForScope(scope);
86 | }
87 | }
88 |
89 | // Modules
90 | connectModule(module) {
91 | this.modulesByIdentifier.set(module.identifier, module);
92 | const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier);
93 | scopes.forEach((scope) => module.connectContextForScope(scope));
94 | }
95 |
96 | disconnectModule(module) {
97 | this.modulesByIdentifier.delete(module.identifier);
98 | const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier);
99 | scopes.forEach((scope) => module.disconnectContextForScope(scope));
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/observe/context.js:
--------------------------------------------------------------------------------
1 | export class Context
2 | {
3 | constructor(module, scope) {
4 | this.module = module;
5 | this.scope = scope;
6 | this.control = new module.controlConstructor(this);
7 |
8 | try {
9 | this.control.initBefore();
10 | this.control.init();
11 | this.control.initAfter();
12 | }
13 | catch (error) {
14 | this.handleError(error, 'initializing control');
15 | }
16 | }
17 |
18 | connect() {
19 | try {
20 | this.control.connectBefore();
21 | this.control.connect();
22 | this.control.connectAfter();
23 | }
24 | catch (error) {
25 | this.handleError(error, 'connecting control');
26 | }
27 | }
28 |
29 | refresh() {
30 | }
31 |
32 | disconnect() {
33 | try {
34 | this.control.disconnectBefore();
35 | this.control.disconnect();
36 | this.control.disconnectAfter();
37 | }
38 | catch (error) {
39 | this.handleError(error, 'disconnecting control');
40 | }
41 | }
42 |
43 | get application() {
44 | return this.module.application;
45 | }
46 |
47 | get identifier() {
48 | return this.module.identifier;
49 | }
50 |
51 | get dispatcher() {
52 | return this.application.dispatcher;
53 | }
54 |
55 | get element() {
56 | return this.scope.element;
57 | }
58 |
59 | get parentElement() {
60 | return this.element.parentElement;
61 | }
62 |
63 | // Error handling
64 | handleError(error, message, detail = {}) {
65 | const { identifier, control, element } = this;
66 | detail = Object.assign({ identifier, control, element }, detail);
67 | this.application.handleError(error, `Error ${message}`, detail);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/observe/control-base.js:
--------------------------------------------------------------------------------
1 |
2 | class ControlBase
3 | {
4 | static proxyCounter = 0;
5 |
6 | static get shouldLoad() {
7 | return true;
8 | }
9 |
10 | static afterLoad(_identifier, _application) {
11 | return;
12 | }
13 |
14 | constructor(context) {
15 | this.context = context;
16 |
17 | this.config = { ...(context.scope.element.dataset || {}) };
18 | }
19 |
20 | get application() {
21 | return this.context.application;
22 | }
23 |
24 | get scope() {
25 | return this.context.scope;
26 | }
27 |
28 | get element() {
29 | return this.scope.element;
30 | }
31 |
32 | get identifier() {
33 | return this.scope.identifier;
34 | }
35 |
36 | init() {
37 | // Set up initial control state
38 | }
39 |
40 | connect() {
41 | // Control is connected to the DOM
42 | }
43 |
44 | disconnect() {
45 | // Control is disconnected from the DOM
46 | }
47 |
48 | // Internal events avoid the need to call parent logic
49 | initBefore() {
50 | this.proxiedEvents = {};
51 | this.proxiedMethods = {};
52 | }
53 |
54 | initAfter() {
55 | }
56 |
57 | connectBefore() {
58 | }
59 |
60 | connectAfter() {
61 | }
62 |
63 | disconnectBefore() {
64 | }
65 |
66 | disconnectAfter() {
67 | for (const key in this.proxiedEvents) {
68 | this.forget(...this.proxiedEvents[key]);
69 | delete this.proxiedEvents[key];
70 | }
71 |
72 | for (const key in this.proxiedMethods) {
73 | this.proxiedMethods[key] = undefined;
74 | }
75 | }
76 |
77 | // Events
78 | listen(eventName, targetOrHandler, handlerOrOptions, options) {
79 | if (typeof targetOrHandler === 'string') {
80 | oc.Events.on(this.element, eventName, targetOrHandler, this.proxy(handlerOrOptions), options);
81 | }
82 | else if (targetOrHandler instanceof Element) {
83 | oc.Events.on(targetOrHandler, eventName, this.proxy(handlerOrOptions), options);
84 | }
85 | else {
86 | oc.Events.on(this.element, eventName, this.proxy(targetOrHandler), handlerOrOptions);
87 | }
88 |
89 | // Automatic unbinding
90 | ControlBase.proxyCounter++;
91 | this.proxiedEvents[ControlBase.proxyCounter] = arguments;
92 | }
93 |
94 | forget(eventName, targetOrHandler, handlerOrOptions, options) {
95 | if (typeof targetOrHandler === 'string') {
96 | oc.Events.off(this.element, eventName, targetOrHandler, this.proxy(handlerOrOptions), options);
97 | }
98 | else if (targetOrHandler instanceof Element) {
99 | oc.Events.off(targetOrHandler, eventName, this.proxy(handlerOrOptions), options);
100 | }
101 | else {
102 | oc.Events.off(this.element, eventName, this.proxy(targetOrHandler), handlerOrOptions);
103 | }
104 |
105 | // Fills JS gap
106 | const compareArrays = (a, b) => {
107 | if (a.length === b.length) {
108 | for (var i = 0; i < a.length; i++) {
109 | if (a[i] === b[i]) {
110 | return true;
111 | }
112 | }
113 | }
114 | return false;
115 | };
116 |
117 | // Seeking GC
118 | for (const key in this.proxiedEvents) {
119 | if (compareArrays(arguments, this.proxiedEvents[key])) {
120 | delete this.proxiedEvents[key];
121 | }
122 | }
123 | }
124 |
125 | dispatch(eventName, { target = this.element, detail = {}, prefix = this.identifier, bubbles = true, cancelable = true, } = {}) {
126 | const type = prefix ? `${prefix}:${eventName}` : eventName;
127 | const event = new CustomEvent(type, { detail, bubbles, cancelable });
128 | target.dispatchEvent(event);
129 | return event;
130 | }
131 |
132 | proxy(method) {
133 | if (method.ocProxyId === undefined) {
134 | ControlBase.proxyCounter++;
135 | method.ocProxyId = ControlBase.proxyCounter;
136 | }
137 |
138 | if (this.proxiedMethods[method.ocProxyId] !== undefined) {
139 | return this.proxiedMethods[method.ocProxyId];
140 | }
141 |
142 | this.proxiedMethods[method.ocProxyId] = method.bind(this);
143 |
144 | return this.proxiedMethods[method.ocProxyId];
145 | }
146 | }
147 |
148 | export { ControlBase };
149 |
--------------------------------------------------------------------------------
/src/observe/dispatcher.js:
--------------------------------------------------------------------------------
1 | import { EventListener } from "./event-listener";
2 |
3 | export class Dispatcher
4 | {
5 | constructor(application) {
6 | this.application = application;
7 | this.eventListenerMaps = new Map();
8 | this.started = false;
9 | }
10 |
11 | start() {
12 | if (!this.started) {
13 | this.started = true;
14 | this.eventListeners.forEach((eventListener) => eventListener.connect());
15 | }
16 | }
17 |
18 | stop() {
19 | if (this.started) {
20 | this.started = false;
21 | this.eventListeners.forEach((eventListener) => eventListener.disconnect());
22 | }
23 | }
24 |
25 | get eventListeners() {
26 | return Array.from(this.eventListenerMaps.values()).reduce((listeners, map) => listeners.concat(Array.from(map.values())), []);
27 | }
28 |
29 | // Binding observer delegate
30 | bindingConnected(binding) {
31 | this.fetchEventListenerForBinding(binding).bindingConnected(binding);
32 | }
33 |
34 | bindingDisconnected(binding, clearEventListeners = false) {
35 | this.fetchEventListenerForBinding(binding).bindingDisconnected(binding);
36 | if (clearEventListeners)
37 | this.clearEventListenersForBinding(binding);
38 | }
39 |
40 | // Error handling
41 | handleError(error, message, detail = {}) {
42 | this.application.handleError(error, `Error ${message}`, detail);
43 | }
44 |
45 | clearEventListenersForBinding(binding) {
46 | const eventListener = this.fetchEventListenerForBinding(binding);
47 | if (!eventListener.hasBindings()) {
48 | eventListener.disconnect();
49 | this.removeMappedEventListenerFor(binding);
50 | }
51 | }
52 |
53 | removeMappedEventListenerFor(binding) {
54 | const { eventTarget, eventName, eventOptions } = binding;
55 | const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget);
56 | const cacheKey = this.cacheKey(eventName, eventOptions);
57 | eventListenerMap.delete(cacheKey);
58 |
59 | if (eventListenerMap.size == 0) {
60 | this.eventListenerMaps.delete(eventTarget);
61 | }
62 | }
63 |
64 | fetchEventListenerForBinding(binding) {
65 | const { eventTarget, eventName, eventOptions } = binding;
66 | return this.fetchEventListener(eventTarget, eventName, eventOptions);
67 | }
68 |
69 | fetchEventListener(eventTarget, eventName, eventOptions) {
70 | const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget);
71 | const cacheKey = this.cacheKey(eventName, eventOptions);
72 | let eventListener = eventListenerMap.get(cacheKey);
73 | if (!eventListener) {
74 | eventListener = this.createEventListener(eventTarget, eventName, eventOptions);
75 | eventListenerMap.set(cacheKey, eventListener);
76 | }
77 | return eventListener;
78 | }
79 |
80 | createEventListener(eventTarget, eventName, eventOptions) {
81 | const eventListener = new EventListener(eventTarget, eventName, eventOptions);
82 | if (this.started) {
83 | eventListener.connect();
84 | }
85 | return eventListener;
86 | }
87 |
88 | fetchEventListenerMapForEventTarget(eventTarget) {
89 | let eventListenerMap = this.eventListenerMaps.get(eventTarget);
90 | if (!eventListenerMap) {
91 | eventListenerMap = new Map();
92 | this.eventListenerMaps.set(eventTarget, eventListenerMap);
93 | }
94 | return eventListenerMap;
95 | }
96 |
97 | cacheKey(eventName, eventOptions) {
98 | const parts = [eventName];
99 | Object.keys(eventOptions)
100 | .sort()
101 | .forEach((key) => {
102 | parts.push(`${eventOptions[key] ? "" : "!"}${key}`);
103 | });
104 | return parts.join(":");
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/observe/event-listener.js:
--------------------------------------------------------------------------------
1 | export class EventListener
2 | {
3 | constructor(eventTarget, eventName, eventOptions) {
4 | this.eventTarget = eventTarget;
5 | this.eventName = eventName;
6 | this.eventOptions = eventOptions;
7 | this.unorderedBindings = new Set();
8 | }
9 |
10 | connect() {
11 | this.eventTarget.addEventListener(this.eventName, this, this.eventOptions);
12 | }
13 |
14 | disconnect() {
15 | this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions);
16 | }
17 |
18 | // Binding observer delegate
19 | bindingConnected(binding) {
20 | this.unorderedBindings.add(binding);
21 | }
22 |
23 | bindingDisconnected(binding) {
24 | this.unorderedBindings.delete(binding);
25 | }
26 |
27 | handleEvent(event) {
28 | const extendedEvent = extendEvent(event);
29 | for (const binding of this.bindings) {
30 | if (extendedEvent.immediatePropagationStopped) {
31 | break;
32 | }
33 | else {
34 | binding.handleEvent(extendedEvent);
35 | }
36 | }
37 | }
38 |
39 | hasBindings() {
40 | return this.unorderedBindings.size > 0;
41 | }
42 |
43 | get bindings() {
44 | return Array.from(this.unorderedBindings).sort((left, right) => {
45 | const leftIndex = left.index, rightIndex = right.index;
46 | return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0;
47 | });
48 | }
49 | }
50 |
51 | function extendEvent(event) {
52 | if ('immediatePropagationStopped' in event) {
53 | return event;
54 | }
55 | else {
56 | const { stopImmediatePropagation } = event;
57 | return Object.assign(event, {
58 | immediatePropagationStopped: false,
59 | stopImmediatePropagation() {
60 | this.immediatePropagationStopped = true;
61 | stopImmediatePropagation.call(this);
62 | },
63 | });
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/observe/index.js:
--------------------------------------------------------------------------------
1 | import { ControlBase } from "./control-base";
2 | import namespace from "./namespace";
3 | export default namespace;
4 |
5 | if (!window.oc) {
6 | window.oc = {};
7 | }
8 |
9 | if (!window.oc.AjaxObserve) {
10 | // Namespace
11 | window.oc.AjaxObserve = namespace;
12 |
13 | // Control API
14 | window.oc.registerControl = namespace.registerControl;
15 |
16 | window.oc.importControl = namespace.importControl;
17 |
18 | window.oc.observeControl = namespace.observeControl;
19 |
20 | window.oc.fetchControl = namespace.fetchControl;
21 |
22 | window.oc.fetchControls = namespace.fetchControls;
23 |
24 | // Control base class
25 | window.oc.ControlBase = ControlBase;
26 |
27 | // Boot controller
28 | if (!isAMD() && !isCommonJS()) {
29 | namespace.start();
30 | }
31 | }
32 |
33 | function isAMD() {
34 | return typeof define == "function" && define.amd;
35 | }
36 |
37 | function isCommonJS() {
38 | return typeof exports == "object" && typeof module != "undefined";
39 | }
40 |
--------------------------------------------------------------------------------
/src/observe/module.js:
--------------------------------------------------------------------------------
1 | import { Context } from "./context";
2 |
3 | export class Module
4 | {
5 | constructor(application, definition) {
6 | this.application = application;
7 | this.definition = blessDefinition(definition);
8 | this.contextsByScope = new WeakMap();
9 | this.connectedContexts = new Set();
10 | }
11 |
12 | get identifier() {
13 | return this.definition.identifier;
14 | }
15 |
16 | get controlConstructor() {
17 | return this.definition.controlConstructor;
18 | }
19 |
20 | get contexts() {
21 | return Array.from(this.connectedContexts);
22 | }
23 |
24 | connectContextForScope(scope) {
25 | const context = this.fetchContextForScope(scope);
26 | this.connectedContexts.add(context);
27 | context.connect();
28 | }
29 |
30 | disconnectContextForScope(scope) {
31 | const context = this.contextsByScope.get(scope);
32 | if (context) {
33 | this.connectedContexts.delete(context);
34 | context.disconnect();
35 | }
36 | }
37 |
38 | fetchContextForScope(scope) {
39 | let context = this.contextsByScope.get(scope);
40 | if (!context) {
41 | context = new Context(this, scope);
42 | this.contextsByScope.set(scope, context);
43 | }
44 | return context;
45 | }
46 | }
47 |
48 | function blessDefinition(definition) {
49 | return {
50 | identifier: definition.identifier,
51 | controlConstructor: definition.controlConstructor,
52 | };
53 | }
54 |
--------------------------------------------------------------------------------
/src/observe/mutation/attribute-observer.js:
--------------------------------------------------------------------------------
1 | import { ElementObserver } from "./element-observer";
2 |
3 | export class AttributeObserver
4 | {
5 | constructor(element, attributeName, delegate) {
6 | this.delegate = delegate;
7 | this.attributeName = attributeName;
8 | this.elementObserver = new ElementObserver(element, this);
9 | }
10 |
11 | get element() {
12 | return this.elementObserver.element;
13 | }
14 |
15 | get selector() {
16 | return `[${this.attributeName}]`;
17 | }
18 |
19 | start() {
20 | this.elementObserver.start();
21 | }
22 |
23 | pause(callback) {
24 | this.elementObserver.pause(callback);
25 | }
26 |
27 | stop() {
28 | this.elementObserver.stop();
29 | }
30 |
31 | refresh() {
32 | this.elementObserver.refresh();
33 | }
34 |
35 | get started() {
36 | return this.elementObserver.started;
37 | }
38 |
39 | // Element observer delegate
40 | matchElement(element) {
41 | return element.hasAttribute(this.attributeName);
42 | }
43 |
44 | matchElementsInTree(tree) {
45 | const match = this.matchElement(tree) ? [tree] : [];
46 | const matches = Array.from(tree.querySelectorAll(this.selector));
47 | return match.concat(matches);
48 | }
49 |
50 | elementMatched(element) {
51 | if (this.delegate.elementMatchedAttribute) {
52 | this.delegate.elementMatchedAttribute(element, this.attributeName);
53 | }
54 | }
55 |
56 | elementUnmatched(element) {
57 | if (this.delegate.elementUnmatchedAttribute) {
58 | this.delegate.elementUnmatchedAttribute(element, this.attributeName);
59 | }
60 | }
61 |
62 | elementAttributeChanged(element, attributeName) {
63 | if (this.delegate.elementAttributeValueChanged && this.attributeName == attributeName) {
64 | this.delegate.elementAttributeValueChanged(element, attributeName);
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/observe/mutation/element-observer.js:
--------------------------------------------------------------------------------
1 | export class ElementObserver
2 | {
3 | constructor(element, delegate) {
4 | this.mutationObserverInit = { attributes: true, childList: true, subtree: true };
5 | this.element = element;
6 | this.started = false;
7 | this.delegate = delegate;
8 | this.elements = new Set();
9 | this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations));
10 | }
11 |
12 | start() {
13 | if (!this.started) {
14 | this.started = true;
15 | this.mutationObserver.observe(this.element, this.mutationObserverInit);
16 | this.refresh();
17 | }
18 | }
19 |
20 | pause(callback) {
21 | if (this.started) {
22 | this.mutationObserver.disconnect();
23 | this.started = false;
24 | }
25 | callback();
26 | if (!this.started) {
27 | this.mutationObserver.observe(this.element, this.mutationObserverInit);
28 | this.started = true;
29 | }
30 | }
31 |
32 | stop() {
33 | if (this.started) {
34 | this.mutationObserver.takeRecords();
35 | this.mutationObserver.disconnect();
36 | this.started = false;
37 | }
38 | }
39 |
40 | refresh() {
41 | if (this.started) {
42 | const matches = new Set(this.matchElementsInTree());
43 | for (const element of Array.from(this.elements)) {
44 | if (!matches.has(element)) {
45 | this.removeElement(element);
46 | }
47 | }
48 | for (const element of Array.from(matches)) {
49 | this.addElement(element);
50 | }
51 | }
52 | }
53 |
54 | // Mutation record processing
55 | processMutations(mutations) {
56 | if (this.started) {
57 | for (const mutation of mutations) {
58 | this.processMutation(mutation);
59 | }
60 | }
61 | }
62 |
63 | processMutation(mutation) {
64 | if (mutation.type == "attributes") {
65 | this.processAttributeChange(mutation.target, mutation.attributeName);
66 | }
67 | else if (mutation.type == "childList") {
68 | this.processRemovedNodes(mutation.removedNodes);
69 | this.processAddedNodes(mutation.addedNodes);
70 | }
71 | }
72 |
73 | processAttributeChange(element, attributeName) {
74 | if (this.elements.has(element)) {
75 | if (this.delegate.elementAttributeChanged && this.matchElement(element)) {
76 | this.delegate.elementAttributeChanged(element, attributeName);
77 | }
78 | else {
79 | this.removeElement(element);
80 | }
81 | }
82 | else if (this.matchElement(element)) {
83 | this.addElement(element);
84 | }
85 | }
86 |
87 | processRemovedNodes(nodes) {
88 | for (const node of Array.from(nodes)) {
89 | const element = this.elementFromNode(node);
90 | if (element) {
91 | this.processTree(element, this.removeElement);
92 | }
93 | }
94 | }
95 |
96 | processAddedNodes(nodes) {
97 | for (const node of Array.from(nodes)) {
98 | const element = this.elementFromNode(node);
99 | if (element && this.elementIsActive(element)) {
100 | this.processTree(element, this.addElement);
101 | }
102 | }
103 | }
104 |
105 | // Element matching
106 | matchElement(element) {
107 | return this.delegate.matchElement(element);
108 | }
109 |
110 | matchElementsInTree(tree = this.element) {
111 | return this.delegate.matchElementsInTree(tree);
112 | }
113 |
114 | processTree(tree, processor) {
115 | for (const element of this.matchElementsInTree(tree)) {
116 | processor.call(this, element);
117 | }
118 | }
119 |
120 | elementFromNode(node) {
121 | if (node.nodeType == Node.ELEMENT_NODE) {
122 | return node;
123 | }
124 | }
125 |
126 | elementIsActive(element) {
127 | if (element.isConnected != this.element.isConnected) {
128 | return false;
129 | }
130 | else {
131 | return this.element.contains(element);
132 | }
133 | }
134 |
135 | // Element tracking
136 | addElement(element) {
137 | if (!this.elements.has(element)) {
138 | if (this.elementIsActive(element)) {
139 | this.elements.add(element);
140 | if (this.delegate.elementMatched) {
141 | this.delegate.elementMatched(element);
142 | }
143 | }
144 | }
145 | }
146 |
147 | removeElement(element) {
148 | if (this.elements.has(element)) {
149 | this.elements.delete(element);
150 | if (this.delegate.elementUnmatched) {
151 | this.delegate.elementUnmatched(element);
152 | }
153 | }
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/observe/mutation/index.js:
--------------------------------------------------------------------------------
1 | export * from "./attribute-observer";
2 | export * from "./element-observer";
3 | export * from "./selector-observer";
4 | export * from "./token-list-observer";
5 | export * from "./value-list-observer";
6 |
--------------------------------------------------------------------------------
/src/observe/mutation/selector-observer.js:
--------------------------------------------------------------------------------
1 | import { ElementObserver } from "./element-observer";
2 | import { Multimap } from "../util/multimap";
3 |
4 | export class SelectorObserver
5 | {
6 | constructor(element, selector, delegate, details) {
7 | this._selector = selector;
8 | this.details = details;
9 | this.elementObserver = new ElementObserver(element, this);
10 | this.delegate = delegate;
11 | this.matchesByElement = new Multimap();
12 | }
13 | get started() {
14 | return this.elementObserver.started;
15 | }
16 | get selector() {
17 | return this._selector;
18 | }
19 | set selector(selector) {
20 | this._selector = selector;
21 | this.refresh();
22 | }
23 | start() {
24 | this.elementObserver.start();
25 | }
26 | pause(callback) {
27 | this.elementObserver.pause(callback);
28 | }
29 | stop() {
30 | this.elementObserver.stop();
31 | }
32 | refresh() {
33 | this.elementObserver.refresh();
34 | }
35 | get element() {
36 | return this.elementObserver.element;
37 | }
38 | // Element observer delegate
39 | matchElement(element) {
40 | const { selector } = this;
41 | if (selector) {
42 | const matches = element.matches(selector);
43 | if (this.delegate.selectorMatchElement) {
44 | return matches && this.delegate.selectorMatchElement(element, this.details);
45 | }
46 | return matches;
47 | }
48 | else {
49 | return false;
50 | }
51 | }
52 | matchElementsInTree(tree) {
53 | const { selector } = this;
54 | if (selector) {
55 | const match = this.matchElement(tree) ? [tree] : [];
56 | const matches = Array.from(tree.querySelectorAll(selector)).filter((match) => this.matchElement(match));
57 | return match.concat(matches);
58 | }
59 | else {
60 | return [];
61 | }
62 | }
63 | elementMatched(element) {
64 | const { selector } = this;
65 | if (selector) {
66 | this.selectorMatched(element, selector);
67 | }
68 | }
69 | elementUnmatched(element) {
70 | const selectors = this.matchesByElement.getKeysForValue(element);
71 | for (const selector of selectors) {
72 | this.selectorUnmatched(element, selector);
73 | }
74 | }
75 | elementAttributeChanged(element, _attributeName) {
76 | const { selector } = this;
77 | if (selector) {
78 | const matches = this.matchElement(element);
79 | const matchedBefore = this.matchesByElement.has(selector, element);
80 | if (matches && !matchedBefore) {
81 | this.selectorMatched(element, selector);
82 | }
83 | else if (!matches && matchedBefore) {
84 | this.selectorUnmatched(element, selector);
85 | }
86 | }
87 | }
88 | // Selector management
89 | selectorMatched(element, selector) {
90 | this.delegate.selectorMatched(element, selector, this.details);
91 | this.matchesByElement.add(selector, element);
92 | }
93 | selectorUnmatched(element, selector) {
94 | this.delegate.selectorUnmatched(element, selector, this.details);
95 | this.matchesByElement.delete(selector, element);
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/observe/mutation/token-list-observer.js:
--------------------------------------------------------------------------------
1 | import { AttributeObserver } from "./attribute-observer";
2 | import { Multimap } from "../util/multimap";
3 |
4 | export class TokenListObserver
5 | {
6 | constructor(element, attributeName, delegate) {
7 | this.delegate = delegate;
8 | this.attributeObserver = new AttributeObserver(element, attributeName, this);
9 | this.tokensByElement = new Multimap();
10 | }
11 |
12 | get started() {
13 | return this.attributeObserver.started;
14 | }
15 |
16 | start() {
17 | this.attributeObserver.start();
18 | }
19 |
20 | pause(callback) {
21 | this.attributeObserver.pause(callback);
22 | }
23 |
24 | stop() {
25 | this.attributeObserver.stop();
26 | }
27 |
28 | refresh() {
29 | this.attributeObserver.refresh();
30 | }
31 |
32 | get element() {
33 | return this.attributeObserver.element;
34 | }
35 |
36 | get attributeName() {
37 | return this.attributeObserver.attributeName;
38 | }
39 |
40 | // Attribute observer delegate
41 | elementMatchedAttribute(element) {
42 | this.tokensMatched(this.readTokensForElement(element));
43 | }
44 |
45 | elementAttributeValueChanged(element) {
46 | const [unmatchedTokens, matchedTokens] = this.refreshTokensForElement(element);
47 | this.tokensUnmatched(unmatchedTokens);
48 | this.tokensMatched(matchedTokens);
49 | }
50 |
51 | elementUnmatchedAttribute(element) {
52 | this.tokensUnmatched(this.tokensByElement.getValuesForKey(element));
53 | }
54 |
55 | tokensMatched(tokens) {
56 | tokens.forEach((token) => this.tokenMatched(token));
57 | }
58 |
59 | tokensUnmatched(tokens) {
60 | tokens.forEach((token) => this.tokenUnmatched(token));
61 | }
62 |
63 | tokenMatched(token) {
64 | this.delegate.tokenMatched(token);
65 | this.tokensByElement.add(token.element, token);
66 | }
67 |
68 | tokenUnmatched(token) {
69 | this.delegate.tokenUnmatched(token);
70 | this.tokensByElement.delete(token.element, token);
71 | }
72 |
73 | refreshTokensForElement(element) {
74 | const previousTokens = this.tokensByElement.getValuesForKey(element);
75 | const currentTokens = this.readTokensForElement(element);
76 | const firstDifferingIndex = zip(previousTokens, currentTokens).findIndex(([previousToken, currentToken]) => !tokensAreEqual(previousToken, currentToken));
77 | if (firstDifferingIndex == -1) {
78 | return [[], []];
79 | }
80 | else {
81 | return [previousTokens.slice(firstDifferingIndex), currentTokens.slice(firstDifferingIndex)];
82 | }
83 | }
84 |
85 | readTokensForElement(element) {
86 | const attributeName = this.attributeName;
87 | const tokenString = element.getAttribute(attributeName) || "";
88 | return parseTokenString(tokenString, element, attributeName);
89 | }
90 | }
91 |
92 | function parseTokenString(tokenString, element, attributeName) {
93 | return tokenString
94 | .trim()
95 | .split(/\s+/)
96 | .filter((content) => content.length)
97 | .map((content, index) => ({ element, attributeName, content, index }));
98 | }
99 |
100 | function zip(left, right) {
101 | const length = Math.max(left.length, right.length);
102 | return Array.from({ length }, (_, index) => [left[index], right[index]]);
103 | }
104 |
105 | function tokensAreEqual(left, right) {
106 | return left && right && left.index == right.index && left.content == right.content;
107 | }
108 |
--------------------------------------------------------------------------------
/src/observe/mutation/value-list-observer.js:
--------------------------------------------------------------------------------
1 | import { TokenListObserver } from "./token-list-observer";
2 |
3 | export class ValueListObserver
4 | {
5 | constructor(element, attributeName, delegate) {
6 | this.tokenListObserver = new TokenListObserver(element, attributeName, this);
7 | this.delegate = delegate;
8 | this.parseResultsByToken = new WeakMap();
9 | this.valuesByTokenByElement = new WeakMap();
10 | }
11 |
12 | get started() {
13 | return this.tokenListObserver.started;
14 | }
15 |
16 | start() {
17 | this.tokenListObserver.start();
18 | }
19 |
20 | stop() {
21 | this.tokenListObserver.stop();
22 | }
23 |
24 | refresh() {
25 | this.tokenListObserver.refresh();
26 | }
27 |
28 | get element() {
29 | return this.tokenListObserver.element;
30 | }
31 |
32 | get attributeName() {
33 | return this.tokenListObserver.attributeName;
34 | }
35 |
36 | tokenMatched(token) {
37 | const { element } = token;
38 | const { value } = this.fetchParseResultForToken(token);
39 | if (value) {
40 | this.fetchValuesByTokenForElement(element).set(token, value);
41 | this.delegate.elementMatchedValue(element, value);
42 | }
43 | }
44 |
45 | tokenUnmatched(token) {
46 | const { element } = token;
47 | const { value } = this.fetchParseResultForToken(token);
48 | if (value) {
49 | this.fetchValuesByTokenForElement(element).delete(token);
50 | this.delegate.elementUnmatchedValue(element, value);
51 | }
52 | }
53 |
54 | fetchParseResultForToken(token) {
55 | let parseResult = this.parseResultsByToken.get(token);
56 | if (!parseResult) {
57 | parseResult = this.parseToken(token);
58 | this.parseResultsByToken.set(token, parseResult);
59 | }
60 | return parseResult;
61 | }
62 |
63 | fetchValuesByTokenForElement(element) {
64 | let valuesByToken = this.valuesByTokenByElement.get(element);
65 | if (!valuesByToken) {
66 | valuesByToken = new Map();
67 | this.valuesByTokenByElement.set(element, valuesByToken);
68 | }
69 | return valuesByToken;
70 | }
71 |
72 | parseToken(token) {
73 | try {
74 | const value = this.delegate.parseValueForToken(token);
75 | return { value };
76 | }
77 | catch (error) {
78 | return { error };
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/observe/namespace.js:
--------------------------------------------------------------------------------
1 | import { Application } from "./application";
2 | const application = new Application;
3 |
4 | export default {
5 | application,
6 |
7 | registerControl(id, control) {
8 | return application.register(id, control);
9 | },
10 |
11 | importControl(id) {
12 | return application.import(id);
13 | },
14 |
15 | observeControl(element, id) {
16 | return application.observe(element, id);
17 | },
18 |
19 | fetchControl(element) {
20 | return application.fetch(element);
21 | },
22 |
23 | fetchControls(elements) {
24 | return application.fetchAll(elements);
25 | },
26 |
27 | start() {
28 | application.startAsync();
29 | },
30 |
31 | stop() {
32 | application.stop();
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/src/observe/scope-observer.js:
--------------------------------------------------------------------------------
1 | import { ValueListObserver } from "./mutation";
2 |
3 | export class ScopeObserver
4 | {
5 | constructor(element, delegate) {
6 | this.element = element;
7 | this.delegate = delegate;
8 | this.valueListObserver = new ValueListObserver(this.element, this.controlAttribute, this);
9 | this.scopesByIdentifierByElement = new WeakMap();
10 | this.scopeReferenceCounts = new WeakMap();
11 | }
12 |
13 | start() {
14 | this.valueListObserver.start();
15 | }
16 |
17 | stop() {
18 | this.valueListObserver.stop();
19 | }
20 |
21 | get controlAttribute() {
22 | return 'data-control';
23 | }
24 |
25 | // Value observer delegate
26 | parseValueForToken(token) {
27 | const { element, content: identifier } = token;
28 | const scopesByIdentifier = this.fetchScopesByIdentifierForElement(element);
29 | let scope = scopesByIdentifier.get(identifier);
30 | if (!scope) {
31 | scope = this.delegate.createScopeForElementAndIdentifier(element, identifier);
32 | scopesByIdentifier.set(identifier, scope);
33 | }
34 | return scope;
35 | }
36 |
37 | elementMatchedValue(element, value) {
38 | const referenceCount = (this.scopeReferenceCounts.get(value) || 0) + 1;
39 | this.scopeReferenceCounts.set(value, referenceCount);
40 | if (referenceCount == 1) {
41 | this.delegate.scopeConnected(value);
42 | }
43 | }
44 |
45 | elementUnmatchedValue(element, value) {
46 | const referenceCount = this.scopeReferenceCounts.get(value);
47 | if (referenceCount) {
48 | this.scopeReferenceCounts.set(value, referenceCount - 1);
49 | if (referenceCount == 1) {
50 | this.delegate.scopeDisconnected(value);
51 | }
52 | }
53 | }
54 |
55 | fetchScopesByIdentifierForElement(element) {
56 | let scopesByIdentifier = this.scopesByIdentifierByElement.get(element);
57 | if (!scopesByIdentifier) {
58 | scopesByIdentifier = new Map();
59 | this.scopesByIdentifierByElement.set(element, scopesByIdentifier);
60 | }
61 | return scopesByIdentifier;
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/observe/scope.js:
--------------------------------------------------------------------------------
1 | export class Scope
2 | {
3 | constructor(element, identifier) {
4 | this.element = element;
5 | this.identifier = identifier;
6 |
7 | this.containsElement = (element) => {
8 | return element.closest(this.controlSelector) === this.element;
9 | };
10 | }
11 |
12 | findElement(selector) {
13 | return this.element.matches(selector) ? this.element : this.queryElements(selector).find(this.containsElement);
14 | }
15 |
16 | findAllElements(selector) {
17 | return [
18 | ...(this.element.matches(selector) ? [this.element] : []),
19 | ...this.queryElements(selector).filter(this.containsElement),
20 | ];
21 | }
22 |
23 | queryElements(selector) {
24 | return Array.from(this.element.querySelectorAll(selector));
25 | }
26 |
27 | get controlSelector() {
28 | return attributeValueContainsToken('data-control', this.identifier);
29 | }
30 |
31 | get isDocumentScope() {
32 | return this.element === document.documentElement;
33 | }
34 |
35 | get documentScope() {
36 | return this.isDocumentScope
37 | ? this
38 | : new Scope(document.documentElement, this.identifier);
39 | }
40 | }
41 |
42 | function attributeValueContainsToken(attributeName, token) {
43 | return `[${attributeName}~="${token}"]`
44 | }
45 |
--------------------------------------------------------------------------------
/src/observe/util/multimap.js:
--------------------------------------------------------------------------------
1 | import { add, del } from "./set-operations";
2 |
3 | export class Multimap
4 | {
5 | constructor() {
6 | this.valuesByKey = new Map();
7 | }
8 |
9 | get keys() {
10 | return Array.from(this.valuesByKey.keys());
11 | }
12 |
13 | get values() {
14 | const sets = Array.from(this.valuesByKey.values());
15 | return sets.reduce((values, set) => values.concat(Array.from(set)), React.createElement(V, null), [] > []);
16 | }
17 |
18 | get size() {
19 | const sets = Array.from(this.valuesByKey.values());
20 | return sets.reduce((size, set) => size + set.size, 0);
21 | }
22 |
23 | add(key, value) {
24 | add(this.valuesByKey, key, value);
25 | }
26 |
27 | delete(key, value) {
28 | del(this.valuesByKey, key, value);
29 | }
30 |
31 | has(key, value) {
32 | const values = this.valuesByKey.get(key);
33 | return values != null && values.has(value);
34 | }
35 |
36 | hasKey(key) {
37 | return this.valuesByKey.has(key);
38 | }
39 |
40 | hasValue(value) {
41 | const sets = Array.from(this.valuesByKey.values());
42 | return sets.some((set) => set.has(value));
43 | }
44 |
45 | getValuesForKey(key) {
46 | const values = this.valuesByKey.get(key);
47 | return values ? Array.from(values) : [];
48 | }
49 |
50 | getKeysForValue(value) {
51 | return Array.from(this.valuesByKey)
52 | .filter(([_key, values]) => values.has(value))
53 | .map(([key, _values]) => key);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/observe/util/set-operations.js:
--------------------------------------------------------------------------------
1 | export function add(map, key, value) {
2 | fetch(map, key).add(value);
3 | }
4 |
5 | export function del(map, key, value) {
6 | fetch(map, key).delete(value);
7 | prune(map, key);
8 | }
9 |
10 | export function fetch(map, key) {
11 | let values = map.get(key);
12 | if (!values) {
13 | values = new Set();
14 | map.set(key, values);
15 | }
16 | return values;
17 | }
18 |
19 | export function prune(map, key) {
20 | const values = map.get(key);
21 | if (values != null && values.size == 0) {
22 | map.delete(key);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/request/asset-manager.js:
--------------------------------------------------------------------------------
1 | export class AssetManager
2 | {
3 | static load(collection, callback) {
4 | return (new AssetManager()).loadCollection(collection, callback);
5 | }
6 |
7 | loadCollection(collection, callback) {
8 | var self = this,
9 | jsList = (collection.js) ? collection.js : [],
10 | cssList = (collection.css) ? collection.css : [],
11 | imgList = (collection.img) ? collection.img : [];
12 |
13 | jsList = assGrep(jsList, function(item) {
14 | return !document.querySelector('head script[src="'+item+'"]');
15 | })
16 |
17 | cssList = assGrep(cssList, function(item) {
18 | return !document.querySelector('head link[href="'+item+'"]');
19 | })
20 |
21 | var cssCounter = 0,
22 | jsLoaded = false,
23 | imgLoaded = false;
24 |
25 | if (jsList.length === 0 && cssList.length === 0 && imgList.length === 0) {
26 | callback && callback();
27 | return;
28 | }
29 |
30 | this.loadJavaScript(jsList, function() {
31 | jsLoaded = true;
32 | checkLoaded();
33 | });
34 |
35 | cssList.forEach(function(source) {
36 | self.loadStyleSheet(source, function() {
37 | cssCounter++;
38 | checkLoaded();
39 | });
40 | });
41 |
42 | this.loadImage(imgList, function() {
43 | imgLoaded = true;
44 | checkLoaded();
45 | });
46 |
47 | function checkLoaded() {
48 | if (!imgLoaded) {
49 | return false
50 | }
51 |
52 | if (!jsLoaded) {
53 | return false
54 | }
55 |
56 | if (cssCounter < cssList.length) {
57 | return false
58 | }
59 |
60 | callback && callback();
61 | }
62 | }
63 |
64 | // Loads StyleSheet files
65 | loadStyleSheet(source, callback) {
66 | var cssElement = document.createElement('link');
67 | cssElement.setAttribute('rel', 'stylesheet');
68 | cssElement.setAttribute('type', 'text/css');
69 | cssElement.setAttribute('href', source);
70 | cssElement.addEventListener('load', callback, false);
71 |
72 | if (typeof cssElement != 'undefined') {
73 | document.getElementsByTagName('head')[0].appendChild(cssElement);
74 | }
75 |
76 | return cssElement;
77 | }
78 |
79 | // Loads JavaScript files in sequence
80 | loadJavaScript(sources, callback) {
81 | if (sources.length <= 0) {
82 | return callback();
83 | }
84 |
85 | var self = this,
86 | source = sources.shift(),
87 | jsElement = document.createElement('script');
88 |
89 | jsElement.setAttribute('type', 'text/javascript');
90 | jsElement.setAttribute('src', source);
91 | jsElement.addEventListener('load', function() {
92 | self.loadJavaScript(sources, callback);
93 | }, false);
94 |
95 | if (typeof jsElement != 'undefined') {
96 | document.getElementsByTagName('head')[0].appendChild(jsElement);
97 | }
98 | }
99 |
100 | // Loads Image files
101 | loadImage(sources, callback) {
102 | if (sources.length <= 0) {
103 | return callback();
104 | }
105 |
106 | var loaded = 0;
107 | sources.forEach(function(source) {
108 | var img = new Image()
109 | img.onload = function() {
110 | if (++loaded == sources.length && callback) {
111 | callback();
112 | }
113 | }
114 | img.src = source;
115 | });
116 | }
117 | }
118 |
119 | function assGrep(items, callback) {
120 | var filtered = [],
121 | len = items.length,
122 | i = 0;
123 |
124 | for (i; i < len; i++) {
125 | if (callback(items[i])) {
126 | filtered.push(items[i]);
127 | }
128 | }
129 |
130 | return filtered;
131 | }
132 |
--------------------------------------------------------------------------------
/src/request/data.js:
--------------------------------------------------------------------------------
1 | import { FormSerializer } from "../util/form-serializer";
2 |
3 | export class Data
4 | {
5 | constructor(userData, targetEl, formEl) {
6 | this.userData = userData || {};
7 | this.targetEl = targetEl;
8 | this.formEl = formEl;
9 | }
10 |
11 | // Public
12 | getRequestData() {
13 | let requestData;
14 |
15 | // Serialize form
16 | if (this.formEl) {
17 | requestData = new FormData(this.formEl);
18 | }
19 | else {
20 | requestData = new FormData;
21 | }
22 |
23 | // Add single input data
24 | this.appendSingleInputElement(requestData);
25 |
26 | return requestData;
27 | }
28 |
29 | getAsFormData() {
30 | return this.appendJsonToFormData(
31 | this.getRequestData(),
32 | this.userData
33 | );
34 | }
35 |
36 | getAsQueryString() {
37 | return this.convertFormDataToQuery(
38 | this.getAsFormData()
39 | );
40 | }
41 |
42 | getAsJsonData() {
43 | return JSON.stringify(
44 | this.convertFormDataToJson(
45 | this.getAsFormData()
46 | )
47 | );
48 | }
49 |
50 | // Private
51 | appendSingleInputElement(requestData) {
52 | // Has a form, no target element, or not a singular input
53 | if (this.formEl || !this.targetEl || !isElementInput(this.targetEl)) {
54 | return;
55 | }
56 |
57 | // No name or supplied by user data already
58 | const inputName = this.targetEl.name;
59 | if (!inputName || this.userData[inputName] !== undefined) {
60 | return;
61 | }
62 |
63 | // Include files, if they are any
64 | if (this.targetEl.type === 'file') {
65 | this.targetEl.files.forEach(function(value) {
66 | requestData.append(inputName, value);
67 | });
68 | }
69 | else {
70 | requestData.append(inputName, this.targetEl.value);
71 | }
72 | }
73 |
74 | appendJsonToFormData(formData, useJson, parentKey) {
75 | var self = this;
76 | for (var key in useJson) {
77 | var fieldKey = key;
78 | if (parentKey) {
79 | fieldKey = parentKey + '[' + key + ']';
80 | }
81 |
82 | var value = useJson[key];
83 |
84 | // Object
85 | if (value && value.constructor === {}.constructor) {
86 | this.appendJsonToFormData(formData, value, fieldKey);
87 | }
88 | // Array
89 | else if (value && value.constructor === [].constructor) {
90 | value.forEach(function(v, i) {
91 | if (
92 | v.constructor === {}.constructor ||
93 | v.constructor === [].constructor
94 | ) {
95 | self.appendJsonToFormData(formData, v, fieldKey + '[' + i + ']');
96 | }
97 | else {
98 | formData.append(fieldKey + '[]', self.castJsonToFormData(v));
99 | }
100 | });
101 | }
102 | // Mixed
103 | else {
104 | formData.append(fieldKey, this.castJsonToFormData(value));
105 | }
106 | }
107 |
108 | return formData;
109 | }
110 |
111 | convertFormDataToQuery(formData) {
112 | // Process to a flat object with array values
113 | let flatData = this.formDataToArray(formData);
114 |
115 | // Process HTML names to a query string
116 | return Object.keys(flatData)
117 | .map(function(key) {
118 | if (key.endsWith('[]')) {
119 | return flatData[key].map(function(val) {
120 | return encodeURIComponent(key) + '=' + encodeURIComponent(val);
121 | }).join('&');
122 | }
123 | else {
124 | return encodeURIComponent(key) + '=' + encodeURIComponent(flatData[key]);
125 | }
126 | })
127 | .join('&');
128 | }
129 |
130 | convertFormDataToJson(formData) {
131 | // Process to a flat object with array values
132 | let flatData = this.formDataToArray(formData);
133 |
134 | // Process HTML names to a nested object
135 | let jsonData = {};
136 | for (var key in flatData) {
137 | FormSerializer.assignToObj(jsonData, key, flatData[key]);
138 | }
139 |
140 | return jsonData;
141 | }
142 |
143 | formDataToArray(formData) {
144 | return Object.fromEntries(
145 | Array.from(formData.keys()).map(key => [
146 | key,
147 | key.endsWith('[]')
148 | ? formData.getAll(key)
149 | : formData.getAll(key).pop()
150 | ])
151 | );
152 | }
153 |
154 | castJsonToFormData(val) {
155 | if (val === null) {
156 | return '';
157 | }
158 |
159 | if (val === true) {
160 | return '1';
161 | }
162 |
163 | if (val === false) {
164 | return '0';
165 | }
166 |
167 | return val;
168 | }
169 | }
170 |
171 | function isElementInput(el) {
172 | return ['input', 'select', 'textarea'].includes((el.tagName || '').toLowerCase());
173 | }
174 |
--------------------------------------------------------------------------------
/src/request/index.js:
--------------------------------------------------------------------------------
1 | import { AssetManager } from "./asset-manager";
2 | import namespace from "./namespace";
3 | export default namespace;
4 |
5 | if (!window.oc) {
6 | window.oc = {};
7 | }
8 |
9 | if (!window.oc.AjaxRequest) {
10 | // Namespace
11 | window.oc.AjaxRequest = namespace;
12 |
13 | // Asset manager
14 | window.oc.AssetManager = AssetManager;
15 |
16 | // Request without element
17 | window.oc.ajax = namespace.send;
18 |
19 | // Request on element (framework can override)
20 | if (!window.oc.request) {
21 | window.oc.request = namespace.sendElement;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/request/namespace.js:
--------------------------------------------------------------------------------
1 | import { Request } from "./request";
2 | export default Request;
3 |
--------------------------------------------------------------------------------
/src/request/options.js:
--------------------------------------------------------------------------------
1 | export class Options
2 | {
3 | constructor(handler, options) {
4 | if (!handler) {
5 | throw new Error('The request handler name is not specified.')
6 | }
7 |
8 | if (!handler.match(/^(?:\w+\:{2})?on*/)) {
9 | throw new Error('Invalid handler name. The correct handler name format is: "onEvent".');
10 | }
11 |
12 | if (typeof FormData === 'undefined') {
13 | throw new Error('The browser does not support the FormData interface.');
14 | }
15 |
16 | this.options = options;
17 | this.handler = handler;
18 | }
19 |
20 | static fetch(handler, options) {
21 | return (new this(handler, options)).getRequestOptions();
22 | }
23 |
24 | // Public
25 | getRequestOptions() {
26 | return {
27 | method: 'POST',
28 | url: this.options.url ? this.options.url : window.location.href,
29 | headers: this.buildHeaders(),
30 | responseType: this.options.download === false ? '' : 'blob'
31 | };
32 | }
33 |
34 | // Private
35 | buildHeaders() {
36 | const { handler, options } = this;
37 | const headers = {
38 | 'X-Requested-With': 'XMLHttpRequest',
39 | 'X-OCTOBER-REQUEST-HANDLER': handler
40 | };
41 |
42 | if (!options.files) {
43 | headers['Content-Type'] = options.bulk
44 | ? 'application/json'
45 | : 'application/x-www-form-urlencoded';
46 | }
47 |
48 | if (options.flash) {
49 | headers['X-OCTOBER-REQUEST-FLASH'] = 1;
50 | }
51 |
52 | if (options.partial) {
53 | headers['X-OCTOBER-REQUEST-PARTIAL'] = options.partial;
54 | }
55 |
56 | var partials = this.extractPartials(options.update, options.partial);
57 | if (partials) {
58 | headers['X-OCTOBER-REQUEST-PARTIALS'] = partials;
59 | }
60 |
61 | var xsrfToken = this.getXSRFToken();
62 | if (xsrfToken) {
63 | headers['X-XSRF-TOKEN'] = xsrfToken;
64 | }
65 |
66 | var csrfToken = this.getCSRFToken();
67 | if (csrfToken) {
68 | headers['X-CSRF-TOKEN'] = csrfToken;
69 | }
70 |
71 | if (options.headers && options.headers.constructor === {}.constructor) {
72 | Object.assign(headers, options.headers);
73 | }
74 |
75 | return headers;
76 | }
77 |
78 | extractPartials(update = {}, selfPartial) {
79 | var result = [];
80 |
81 | if (update) {
82 | if (typeof update !== 'object') {
83 | throw new Error('Invalid update value. The correct format is an object ({...})');
84 | }
85 |
86 | for (var partial in update) {
87 | if (partial === '_self' && selfPartial) {
88 | result.push(selfPartial);
89 | }
90 | else {
91 | result.push(partial);
92 | }
93 | }
94 | }
95 |
96 | return result.join('&');
97 | }
98 |
99 | getCSRFToken() {
100 | var tag = document.querySelector('meta[name="csrf-token"]');
101 | return tag ? tag.getAttribute('content') : null;
102 | }
103 |
104 | getXSRFToken() {
105 | var cookieValue = null;
106 | if (document.cookie && document.cookie != '') {
107 | var cookies = document.cookie.split(';');
108 | for (var i = 0; i < cookies.length; i++) {
109 | var cookie = cookies[i].replace(/^([\s]*)|([\s]*)$/g, '');
110 | if (cookie.substring(0, 11) == ('XSRF-TOKEN' + '=')) {
111 | cookieValue = decodeURIComponent(cookie.substring(11));
112 | break;
113 | }
114 | }
115 | }
116 | return cookieValue;
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/request/request.js:
--------------------------------------------------------------------------------
1 | import { Options } from "./options";
2 | import { Actions } from "./actions";
3 | import { Data } from "./data";
4 | import { HttpRequest, SystemStatusCode } from "../util/http-request";
5 | import { Deferred } from "../util/deferred";
6 | import { ProgressBar } from "../extras/progress-bar";
7 | import { dispatch } from "../util";
8 |
9 | export class Request
10 | {
11 | constructor(element, handler, options) {
12 | this.el = element;
13 | this.handler = handler;
14 | this.options = { ...this.constructor.DEFAULTS, ...(options || {}) };
15 | this.context = { el: element, handler: handler, options: this.options };
16 |
17 | this.progressBar = new ProgressBar;
18 | this.showProgressBar = () => {
19 | this.progressBar.show({ cssClass: 'is-ajax' });
20 | };
21 | }
22 |
23 | static get DEFAULTS() {
24 | return {
25 | handler: null,
26 | update: {},
27 | files: false,
28 | bulk: false,
29 | download: false,
30 | browserTarget: null,
31 | browserValidate: false,
32 | browserRedirectBack: false,
33 | progressBarDelay: 500,
34 | progressBar: null
35 | }
36 | }
37 |
38 | start() {
39 | // Setup
40 | if (!this.applicationAllowsSetup()) {
41 | return;
42 | }
43 |
44 | this.initOtherElements();
45 | this.preprocessOptions();
46 |
47 | // Prepare actions
48 | this.actions = new Actions(this, this.context, this.options);
49 | if (!this.validateClientSideForm() || !this.applicationAllowsRequest()) {
50 | return;
51 | }
52 |
53 | // Confirm before sending
54 | if (this.options.confirm && !this.actions.invoke('handleConfirmMessage', [this.options.confirm])) {
55 | return;
56 | }
57 |
58 | // Send request
59 | this.sendInternal();
60 |
61 | return this.options.async
62 | ? this.wrapInAsyncPromise(this.promise)
63 | : this.promise;
64 | }
65 |
66 | sendInternal() {
67 | // Prepare data
68 | const dataObj = new Data(this.options.data, this.el, this.formEl);
69 | let data;
70 | if (this.options.files) {
71 | data = dataObj.getAsFormData();
72 | }
73 | else if (this.options.bulk) {
74 | data = dataObj.getAsJsonData();
75 | }
76 | else {
77 | data = dataObj.getAsQueryString();
78 | }
79 |
80 | // Prepare query
81 | if (this.options.query) {
82 | this.actions.invoke('applyQueryToUrl', [
83 | this.options.query !== true
84 | ? this.options.query
85 | : JSON.parse(dataObj.getAsJsonData())
86 | ]);
87 | }
88 |
89 | // Prepare request
90 | const { url, headers, method, responseType } = Options.fetch(this.handler, this.options);
91 | this.request = new HttpRequest(this, url, { method, headers, responseType, data, trackAbort: true });
92 | this.promise = new Deferred({ delegate: this.request });
93 | this.isRedirect = this.options.redirect && this.options.redirect.length > 0;
94 |
95 | // Lifecycle events
96 | this.notifyApplicationBeforeSend();
97 | this.notifyApplicationAjaxPromise();
98 | this.promise
99 | .fail((data, responseCode, xhr) => {
100 | if (!this.isRedirect) {
101 | this.notifyApplicationAjaxFail(data, responseCode, xhr);
102 | }
103 | })
104 | .done((data, responseCode, xhr) => {
105 | if (!this.isRedirect) {
106 | this.notifyApplicationAjaxDone(data, responseCode, xhr);
107 | }
108 | })
109 | .always((data, responseCode, xhr) => {
110 | this.notifyApplicationAjaxAlways(data, responseCode, xhr);
111 | });
112 |
113 | this.request.send();
114 | }
115 |
116 | static send(handler, options) {
117 | return (new Request(document, handler, options)).start();
118 | }
119 |
120 | static sendElement(element, handler, options) {
121 | if (typeof element === 'string') {
122 | element = document.querySelector(element);
123 | }
124 |
125 | return (new Request(element, handler, options)).start();
126 | }
127 |
128 | toggleRedirect(redirectUrl) {
129 | if (!redirectUrl) {
130 | this.options.redirect = null;
131 | this.isRedirect = false;
132 | }
133 | else {
134 | this.options.redirect = redirectUrl;
135 | this.isRedirect = true;
136 | }
137 | }
138 |
139 | applicationAllowsSetup() {
140 | const event = this.notifyApplicationAjaxSetup();
141 | return !event.defaultPrevented;
142 | }
143 |
144 | applicationAllowsRequest() {
145 | const event = this.notifyApplicationBeforeRequest();
146 | return !event.defaultPrevented;
147 | }
148 |
149 | applicationAllowsUpdate(data, responseCode, xhr) {
150 | const event = this.notifyApplicationBeforeUpdate(data, responseCode, xhr);
151 | return !event.defaultPrevented;
152 | }
153 |
154 | applicationAllowsError(message, responseCode, xhr) {
155 | const event = this.notifyApplicationRequestError(message, responseCode, xhr);
156 | return !event.defaultPrevented;
157 | }
158 |
159 | // Application events
160 | notifyApplicationAjaxSetup() {
161 | return dispatch('ajax:setup', { target: this.el, detail: { context: this.context } });
162 | }
163 |
164 | notifyApplicationAjaxPromise() {
165 | return dispatch('ajax:promise', { target: this.el, detail: { context: this.context } });
166 | }
167 |
168 | notifyApplicationAjaxFail(data, responseCode, xhr) {
169 | return dispatch('ajax:fail', { target: this.el, detail: { context: this.context, data, responseCode, xhr } });
170 | }
171 |
172 | notifyApplicationAjaxDone(data, responseCode, xhr) {
173 | return dispatch('ajax:done', { target: this.el, detail: { context: this.context, data, responseCode, xhr } });
174 | }
175 |
176 | notifyApplicationAjaxAlways(data, responseCode, xhr) {
177 | return dispatch('ajax:always', { target: this.el, detail: { context: this.context, data, responseCode, xhr } });
178 | }
179 |
180 | notifyApplicationAjaxUpdate(target, data, responseCode, xhr) {
181 | return dispatch('ajax:update', { target, detail: { context: this.context, data, responseCode, xhr } });
182 | }
183 |
184 | notifyApplicationBeforeRedirect() {
185 | return dispatch('ajax:before-redirect', { target: this.el });
186 | }
187 |
188 | notifyApplicationBeforeRequest() {
189 | return dispatch('ajax:before-request', { target: this.triggerEl, detail: { context: this.context } });
190 | }
191 |
192 | notifyApplicationBeforeUpdate(data, responseCode, xhr) {
193 | return dispatch('ajax:before-update', { target: this.triggerEl, detail: { context: this.context, data, responseCode, xhr } });
194 | }
195 |
196 | notifyApplicationRequestSuccess(data, responseCode, xhr) {
197 | return dispatch('ajax:request-success', { target: this.triggerEl, detail: { context: this.context, data, responseCode, xhr } });
198 | }
199 |
200 | notifyApplicationRequestError(message, responseCode, xhr) {
201 | return dispatch('ajax:request-error', { target: this.triggerEl, detail: { context: this.context, message, responseCode, xhr } });
202 | }
203 |
204 | notifyApplicationRequestComplete(data, responseCode, xhr) {
205 | return dispatch('ajax:request-complete', { target: this.triggerEl, detail: { context: this.context, data, responseCode, xhr } });
206 | }
207 |
208 | notifyApplicationBeforeValidate(message, fields) {
209 | return dispatch('ajax:before-validate', { target: this.triggerEl, detail: { context: this.context, message, fields } });
210 | }
211 |
212 | notifyApplicationBeforeReplace(target) {
213 | return dispatch('ajax:before-replace', { target });
214 | }
215 |
216 | // Window-based events
217 | notifyApplicationBeforeSend() {
218 | return dispatch('ajax:before-send', { target: window, detail: { context: this.context } });
219 | }
220 |
221 | notifyApplicationUpdateComplete(data, responseCode, xhr) {
222 | return dispatch('ajax:update-complete', { target: window, detail: { context: this.context, data, responseCode, xhr } });
223 | }
224 |
225 | notifyApplicationFieldInvalid(element, fieldName, errorMsg, isFirst) {
226 | return dispatch('ajax:invalid-field', { target: window, detail: { element, fieldName, errorMsg, isFirst } });
227 | }
228 |
229 | notifyApplicationConfirmMessage(message, promise) {
230 | return dispatch('ajax:confirm-message', { target: window, detail: { message, promise } });
231 | }
232 |
233 | notifyApplicationErrorMessage(message) {
234 | return dispatch('ajax:error-message', { target: window, detail: { message } });
235 | }
236 |
237 | notifyApplicationCustomEvent(name, data) {
238 | return dispatch(name, { target: this.el, detail: data });
239 | }
240 |
241 | // HTTP request delegate
242 | requestStarted() {
243 | this.markAsProgress(true);
244 | this.toggleLoadingElement(true);
245 |
246 | if (this.options.progressBar) {
247 | this.showProgressBarAfterDelay();
248 | }
249 |
250 | this.actions.invoke('start', [this.request.xhr]);
251 | }
252 |
253 | requestProgressed(progress) {
254 | this.promise.notify(progress);
255 | }
256 |
257 | requestCompletedWithResponse(response, statusCode) {
258 | this.actions.invoke('success', [response, statusCode, this.request.xhr]);
259 | this.actions.invoke('complete', [response, statusCode, this.request.xhr]);
260 | this.promise.resolve(response, statusCode, this.request.xhr);
261 | }
262 |
263 | requestFailedWithStatusCode(statusCode, response) {
264 | if (statusCode == SystemStatusCode.userAborted) {
265 | this.actions.invoke('cancel', []);
266 | }
267 | else {
268 | this.actions.invoke('error', [response, statusCode, this.request.xhr]);
269 | }
270 |
271 | this.actions.invoke('complete', [response, statusCode, this.request.xhr]);
272 | this.promise.reject(response, statusCode, this.request.xhr);
273 | }
274 |
275 | requestFinished() {
276 | this.markAsProgress(false);
277 | this.toggleLoadingElement(false);
278 |
279 | if (this.options.progressBar) {
280 | this.hideProgressBar();
281 | }
282 | }
283 |
284 | // Private
285 | initOtherElements() {
286 | if (typeof this.options.form === 'string') {
287 | this.formEl = document.querySelector(this.options.form);
288 | }
289 | else if (this.options.form) {
290 | this.formEl = this.options.form;
291 | }
292 | else {
293 | this.formEl = this.el && this.el !== document ? this.el.closest('form') : null;
294 | }
295 |
296 | this.triggerEl = this.formEl ? this.formEl : this.el;
297 |
298 | this.partialEl = this.el && this.el !== document ? this.el.closest('[data-ajax-partial]') : null;
299 |
300 | this.loadingEl = typeof this.options.loading === 'string'
301 | ? document.querySelector(this.options.loading)
302 | : this.options.loading;
303 | }
304 |
305 | preprocessOptions() {
306 | // Partial mode
307 | if (this.options.partial === undefined && this.partialEl && this.partialEl.dataset.ajaxPartial !== undefined) {
308 | this.options.partial = this.partialEl.dataset.ajaxPartial || true;
309 | }
310 | }
311 |
312 | validateClientSideForm() {
313 | if (
314 | this.options.browserValidate &&
315 | typeof document.createElement('input').reportValidity === 'function' &&
316 | this.formEl &&
317 | !this.formEl.checkValidity()
318 | ) {
319 | this.formEl.reportValidity();
320 | return false;
321 | }
322 |
323 | return true;
324 | }
325 |
326 | toggleLoadingElement(isLoading) {
327 | if (!this.loadingEl) {
328 | return;
329 | }
330 |
331 | if (
332 | typeof this.loadingEl.show !== 'function' ||
333 | typeof this.loadingEl.hide !== 'function'
334 | ) {
335 | this.loadingEl.style.display = isLoading ? 'block' : 'none';
336 | return;
337 | }
338 |
339 | if (isLoading) {
340 | this.loadingEl.show();
341 | }
342 | else {
343 | this.loadingEl.hide();
344 | }
345 | }
346 |
347 | showProgressBarAfterDelay() {
348 | this.progressBar.setValue(0);
349 | this.progressBarTimeout = window.setTimeout(this.showProgressBar, this.options.progressBarDelay);
350 | }
351 |
352 | hideProgressBar() {
353 | this.progressBar.setValue(100);
354 | this.progressBar.hide();
355 | if (this.progressBarTimeout != null) {
356 | window.clearTimeout(this.progressBarTimeout);
357 | delete this.progressBarTimeout;
358 | }
359 | }
360 |
361 | markAsProgress(isLoading) {
362 | if (isLoading) {
363 | document.documentElement.setAttribute('data-ajax-progress', '');
364 | if (this.formEl) {
365 | this.formEl.setAttribute('data-ajax-progress', this.handler);
366 | }
367 | }
368 | else {
369 | document.documentElement.removeAttribute('data-ajax-progress');
370 | if (this.formEl) {
371 | this.formEl.removeAttribute('data-ajax-progress');
372 | }
373 | }
374 | }
375 |
376 |
377 | // @todo v2: this needs to pass more than just "data"
378 | // perhaps { data, responseCode, headers }
379 | wrapInAsyncPromise(requestPromise) {
380 | return new Promise(function (resolve, reject, onCancel) {
381 | requestPromise
382 | .fail(function(data) {
383 | reject(data);
384 | })
385 | .done(function(data) {
386 | resolve(data);
387 | });
388 |
389 | if (onCancel) {
390 | onCancel(function() {
391 | requestPromise.abort();
392 | });
393 | }
394 | });
395 | }
396 | }
397 |
--------------------------------------------------------------------------------
/src/turbo/browser-adapter.js:
--------------------------------------------------------------------------------
1 | import { SystemStatusCode } from "../util/http-request";
2 | import { ProgressBar } from "../extras/progress-bar";
3 | import { uuid } from "../util";
4 |
5 | export class BrowserAdapter
6 | {
7 | constructor(controller) {
8 | this.progressBar = new ProgressBar;
9 | this.showProgressBar = () => {
10 | this.progressBar.show({ cssClass: 'is-turbo' });
11 | };
12 | this.controller = controller;
13 | }
14 |
15 | visitProposedToLocationWithAction(location, action) {
16 | const restorationIdentifier = uuid();
17 | this.controller.startVisitToLocationWithAction(location, action, restorationIdentifier);
18 | }
19 |
20 | visitStarted(visit) {
21 | visit.issueRequest();
22 | visit.changeHistory();
23 | visit.goToSamePageAnchor();
24 | visit.loadCachedSnapshot();
25 | }
26 |
27 | visitRequestStarted(visit) {
28 | this.progressBar.setValue(0);
29 | if (visit.hasCachedSnapshot() || visit.action != 'restore') {
30 | this.showProgressBarAfterDelay();
31 | }
32 | else {
33 | this.showProgressBar();
34 | }
35 | }
36 |
37 | visitRequestProgressed(visit) {
38 | this.progressBar.setValue(visit.progress);
39 | }
40 |
41 | visitRequestCompleted(visit) {
42 | visit.loadResponse();
43 | }
44 |
45 | visitRequestFailedWithStatusCode(visit, statusCode) {
46 | switch (statusCode) {
47 | case SystemStatusCode.networkFailure:
48 | case SystemStatusCode.timeoutFailure:
49 | case SystemStatusCode.contentTypeMismatch:
50 | case SystemStatusCode.userAborted:
51 | return this.reload();
52 | default:
53 | return visit.loadResponse();
54 | }
55 | }
56 |
57 | visitRequestFinished(visit) {
58 | this.hideProgressBar();
59 | }
60 |
61 | visitCompleted(visit) {
62 | visit.followRedirect();
63 | }
64 |
65 | pageInvalidated() {
66 | this.reload();
67 | }
68 |
69 | visitFailed(visit) {
70 | }
71 |
72 | visitRendered(visit) {
73 | }
74 |
75 | // Private
76 | showProgressBarAfterDelay() {
77 | if (this.controller.progressBarVisible) {
78 | this.progressBarTimeout = window.setTimeout(this.showProgressBar, this.controller.progressBarDelay);
79 | }
80 | }
81 |
82 | hideProgressBar() {
83 | if (this.controller.progressBarVisible) {
84 | this.progressBar.hide();
85 | if (this.progressBarTimeout !== null) {
86 | window.clearTimeout(this.progressBarTimeout);
87 | delete this.progressBarTimeout;
88 | }
89 | }
90 | }
91 |
92 | reload() {
93 | window.location.reload();
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/turbo/error-renderer.js:
--------------------------------------------------------------------------------
1 | import { Renderer } from "./renderer";
2 | import { array } from "../util";
3 |
4 | export class ErrorRenderer extends Renderer
5 | {
6 | constructor(delegate, html) {
7 | super();
8 | this.delegate = delegate;
9 | this.htmlElement = (() => {
10 | const htmlElement = document.createElement('html');
11 | htmlElement.innerHTML = html;
12 | return htmlElement;
13 | })();
14 | this.newHead = this.htmlElement.querySelector('head') || document.createElement('head');
15 | this.newBody = this.htmlElement.querySelector('body') || document.createElement('body');
16 | }
17 |
18 | static render(delegate, callback, html) {
19 | return new this(delegate, html).render(callback);
20 | }
21 |
22 | render(callback) {
23 | this.renderView(() => {
24 | this.replaceHeadAndBody();
25 | this.activateBodyScriptElements();
26 | callback();
27 | });
28 | }
29 |
30 | replaceHeadAndBody() {
31 | const { documentElement, head, body } = document;
32 | documentElement.replaceChild(this.newHead, head);
33 | documentElement.replaceChild(this.newBody, body);
34 | }
35 |
36 | activateBodyScriptElements() {
37 | for (const replaceableElement of this.getScriptElements()) {
38 | const parentNode = replaceableElement.parentNode;
39 | if (parentNode) {
40 | const element = this.createScriptElement(replaceableElement);
41 | parentNode.replaceChild(element, replaceableElement);
42 | }
43 | }
44 | }
45 |
46 | getScriptElements() {
47 | return array(document.documentElement.querySelectorAll('script'));
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/turbo/head-details.js:
--------------------------------------------------------------------------------
1 | import { array } from "../util";
2 |
3 | export class HeadDetails
4 | {
5 | constructor(children) {
6 | this.detailsByOuterHTML = children.reduce((result, element) => {
7 | const { outerHTML } = element;
8 | const details = outerHTML in result
9 | ? result[outerHTML]
10 | : {
11 | type: elementType(element),
12 | tracked: elementIsTracked(element),
13 | elements: []
14 | };
15 | return Object.assign(Object.assign({}, result), { [outerHTML]: Object.assign(Object.assign({}, details), { elements: [...details.elements, element] }) });
16 | }, {});
17 | }
18 |
19 | static fromHeadElement(headElement) {
20 | const children = headElement ? array(headElement.children) : [];
21 | return new this(children);
22 | }
23 |
24 | getTrackedElementSignature() {
25 | return Object.keys(this.detailsByOuterHTML)
26 | .filter(outerHTML => this.detailsByOuterHTML[outerHTML].tracked)
27 | .join("");
28 | }
29 |
30 | getScriptElementsNotInDetails(headDetails) {
31 | return this.getElementsMatchingTypeNotInDetails('script', headDetails);
32 | }
33 |
34 | getStylesheetElementsNotInDetails(headDetails) {
35 | return this.getElementsMatchingTypeNotInDetails('stylesheet', headDetails);
36 | }
37 |
38 | getElementsMatchingTypeNotInDetails(matchedType, headDetails) {
39 | return Object.keys(this.detailsByOuterHTML)
40 | .filter(outerHTML => !(outerHTML in headDetails.detailsByOuterHTML))
41 | .map(outerHTML => this.detailsByOuterHTML[outerHTML])
42 | .filter(({ type }) => type == matchedType)
43 | .map(({ elements: [element] }) => element);
44 | }
45 |
46 | getProvisionalElements() {
47 | return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
48 | const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML];
49 | if (type == null && !tracked) {
50 | return [...result, ...elements];
51 | }
52 | else if (elements.length > 1) {
53 | return [...result, ...elements.slice(1)];
54 | }
55 | else {
56 | return result;
57 | }
58 | }, []);
59 | }
60 |
61 | getMetaValue(name) {
62 | const element = this.findMetaElementByName(name);
63 | return element
64 | ? element.getAttribute('content')
65 | : null;
66 | }
67 |
68 | findMetaElementByName(name) {
69 | return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
70 | const { elements: [element] } = this.detailsByOuterHTML[outerHTML];
71 | return elementIsMetaElementWithName(element, name) ? element : result;
72 | }, undefined);
73 | }
74 | }
75 |
76 | function elementType(element) {
77 | if (elementIsScript(element)) {
78 | return 'script';
79 | }
80 |
81 | else if (elementIsStylesheet(element)) {
82 | return 'stylesheet';
83 | }
84 | }
85 |
86 | function elementIsTracked(element) {
87 | return element.getAttribute('data-turbo-track') == 'reload';
88 | }
89 |
90 | function elementIsScript(element) {
91 | const tagName = element.tagName.toLowerCase();
92 | return tagName == 'script';
93 | }
94 |
95 | function elementIsStylesheet(element) {
96 | const tagName = element.tagName.toLowerCase();
97 | return tagName == 'style' || (tagName == 'link' && element.getAttribute('rel') == 'stylesheet');
98 | }
99 |
100 | function elementIsMetaElementWithName(element, name) {
101 | const tagName = element.tagName.toLowerCase();
102 | return tagName == 'meta' && element.getAttribute('name') == name;
103 | }
104 |
--------------------------------------------------------------------------------
/src/turbo/history.js:
--------------------------------------------------------------------------------
1 | import { Location } from "./location";
2 | import { defer } from "../util";
3 |
4 | export class History
5 | {
6 | constructor(delegate) {
7 | this.started = false;
8 | this.pageLoaded = false;
9 |
10 | // Event handlers
11 | this.onPopState = (event) => {
12 | if (!this.shouldHandlePopState()) {
13 | return;
14 | }
15 |
16 | if (!event.state || !event.state.ajaxTurbo) {
17 | return;
18 | }
19 |
20 | const { ajaxTurbo } = event.state;
21 | const location = Location.currentLocation;
22 | const { restorationIdentifier } = ajaxTurbo;
23 |
24 | this.delegate.historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier);
25 | };
26 |
27 | this.onPageLoad = (event) => {
28 | defer(() => {
29 | this.pageLoaded = true;
30 | });
31 | };
32 |
33 | this.delegate = delegate;
34 | }
35 |
36 | start() {
37 | if (!this.started) {
38 | addEventListener('popstate', this.onPopState, false);
39 | addEventListener('load', this.onPageLoad, false);
40 | this.started = true;
41 | }
42 | }
43 |
44 | stop() {
45 | if (this.started) {
46 | removeEventListener('popstate', this.onPopState, false);
47 | removeEventListener('load', this.onPageLoad, false);
48 | this.started = false;
49 | }
50 | }
51 |
52 | push(location, restorationIdentifier) {
53 | this.update(history.pushState, location, restorationIdentifier);
54 | }
55 |
56 | replace(location, restorationIdentifier) {
57 | this.update(history.replaceState, location, restorationIdentifier);
58 | }
59 |
60 | // Private
61 | shouldHandlePopState() {
62 | // Safari dispatches a popstate event after window's load event, ignore it
63 | return this.pageIsLoaded();
64 | }
65 |
66 | pageIsLoaded() {
67 | return this.pageLoaded || document.readyState == 'complete';
68 | }
69 |
70 | update(method, location, restorationIdentifier) {
71 | const state = { ajaxTurbo: { restorationIdentifier } };
72 |
73 | method.call(history, state, '', location.absoluteURL);
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/turbo/index.js:
--------------------------------------------------------------------------------
1 | import namespace from "./namespace";
2 | export default namespace;
3 |
4 | if (!window.oc) {
5 | window.oc = {};
6 | }
7 |
8 | if (!window.oc.AjaxTurbo) {
9 | // Namespace
10 | window.oc.AjaxTurbo = namespace;
11 |
12 | // Visit helper
13 | window.oc.visit = namespace.visit;
14 |
15 | // Enabled helper
16 | window.oc.useTurbo = namespace.isEnabled;
17 |
18 | // Page ready helper
19 | window.oc.pageReady = namespace.pageReady;
20 |
21 | // Boot controller
22 | if (!isAMD() && !isCommonJS()) {
23 | namespace.start();
24 | }
25 | }
26 |
27 | function isAMD() {
28 | return typeof define == "function" && define.amd;
29 | }
30 |
31 | function isCommonJS() {
32 | return typeof exports == "object" && typeof module != "undefined";
33 | }
34 |
--------------------------------------------------------------------------------
/src/turbo/location.js:
--------------------------------------------------------------------------------
1 | export class Location
2 | {
3 | constructor(url) {
4 | const linkWithAnchor = document.createElement('a');
5 | linkWithAnchor.href = url;
6 | this.absoluteURL = linkWithAnchor.href;
7 | const anchorLength = linkWithAnchor.hash.length;
8 | if (anchorLength < 2) {
9 | this.requestURL = this.absoluteURL;
10 | }
11 | else {
12 | this.requestURL = this.absoluteURL.slice(0, -anchorLength);
13 | this.anchor = linkWithAnchor.hash.slice(1);
14 | }
15 | }
16 |
17 | static get currentLocation() {
18 | return this.wrap(window.location.toString());
19 | }
20 |
21 | static wrap(locatable) {
22 | if (typeof locatable == 'string') {
23 | return new Location(locatable);
24 | }
25 | else if (locatable != null) {
26 | return locatable;
27 | }
28 | }
29 |
30 | getOrigin() {
31 | return this.absoluteURL.split("/", 3).join("/");
32 | }
33 |
34 | getPath() {
35 | return (this.requestURL.match(/\/\/[^/]*(\/[^?;]*)/) || [])[1] || "/";
36 | }
37 |
38 | getPathComponents() {
39 | return this.getPath().split("/").slice(1);
40 | }
41 |
42 | getLastPathComponent() {
43 | return this.getPathComponents().slice(-1)[0];
44 | }
45 |
46 | getExtension() {
47 | return (this.getLastPathComponent().match(/\.[^.]*$/) || [])[0] || "";
48 | }
49 |
50 | isHTML() {
51 | return this.getExtension().match(/^(?:|\.(?:htm|html|xhtml))$/);
52 | }
53 |
54 | isPrefixedBy(location) {
55 | const prefixURL = getPrefixURL(location);
56 | return this.isEqualTo(location) || stringStartsWith(this.absoluteURL, prefixURL);
57 | }
58 |
59 | isEqualTo(location) {
60 | return location && this.absoluteURL === location.absoluteURL;
61 | }
62 |
63 | toCacheKey() {
64 | return this.requestURL;
65 | }
66 |
67 | toJSON() {
68 | return this.absoluteURL;
69 | }
70 |
71 | toString() {
72 | return this.absoluteURL;
73 | }
74 |
75 | valueOf() {
76 | return this.absoluteURL;
77 | }
78 | }
79 |
80 | function getPrefixURL(location) {
81 | return addTrailingSlash(location.getOrigin() + location.getPath());
82 | }
83 |
84 | function addTrailingSlash(url) {
85 | return stringEndsWith(url, "/") ? url : url + "/";
86 | }
87 |
88 | function stringStartsWith(string, prefix) {
89 | return string.slice(0, prefix.length) === prefix;
90 | }
91 |
92 | function stringEndsWith(string, suffix) {
93 | return string.slice(-suffix.length) === suffix;
94 | }
95 |
--------------------------------------------------------------------------------
/src/turbo/namespace.js:
--------------------------------------------------------------------------------
1 | import { Controller } from "./controller";
2 | const controller = new Controller;
3 |
4 | export default {
5 | get supported() {
6 | return Controller.supported;
7 | },
8 |
9 | controller,
10 |
11 | visit(location, options) {
12 | controller.visit(location, options);
13 | },
14 |
15 | clearCache() {
16 | controller.clearCache();
17 | },
18 |
19 | setProgressBarVisible(value) {
20 | controller.setProgressBarVisible(value);
21 | },
22 |
23 | setProgressBarDelay(delay) {
24 | controller.setProgressBarDelay(delay);
25 | },
26 |
27 | start() {
28 | controller.start();
29 | },
30 |
31 | isEnabled() {
32 | return controller.isEnabled();
33 | },
34 |
35 | pageReady() {
36 | return controller.pageReady();
37 | }
38 | };
39 |
--------------------------------------------------------------------------------
/src/turbo/renderer.js:
--------------------------------------------------------------------------------
1 | import { array } from "../util";
2 |
3 | export class Renderer
4 | {
5 | renderView(callback) {
6 | const renderInterception = () => {
7 | callback();
8 | this.delegate.viewRendered(this.newBody);
9 | };
10 |
11 | const options = { resume: renderInterception };
12 | const immediateRender = this.delegate.viewAllowsImmediateRender(this.newBody, options);
13 | if (immediateRender) {
14 | renderInterception();
15 | }
16 | }
17 |
18 | invalidateView() {
19 | this.delegate.viewInvalidated();
20 | }
21 |
22 | createScriptElement(element) {
23 | if (
24 | element.getAttribute('data-turbo-eval') === 'false' ||
25 | this.delegate.applicationHasSeenInlineScript(element)
26 | ) {
27 | return element;
28 | }
29 |
30 | const createdScriptElement = document.createElement('script');
31 | createdScriptElement.textContent = element.textContent;
32 | createdScriptElement.async = false;
33 | copyElementAttributes(createdScriptElement, element);
34 | return createdScriptElement;
35 | }
36 | }
37 |
38 | function copyElementAttributes(destinationElement, sourceElement) {
39 | for (const { name, value } of array(sourceElement.attributes)) {
40 | destinationElement.setAttribute(name, value);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/turbo/scroll-manager.js:
--------------------------------------------------------------------------------
1 | export class ScrollManager
2 | {
3 | constructor(delegate) {
4 | this.started = false;
5 | this.onScroll = () => {
6 | this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset });
7 | };
8 | this.delegate = delegate;
9 | }
10 |
11 | start() {
12 | if (!this.started) {
13 | addEventListener('scroll', this.onScroll, false);
14 | this.onScroll();
15 | this.started = true;
16 | }
17 | }
18 |
19 | stop() {
20 | if (this.started) {
21 | removeEventListener('scroll', this.onScroll, false);
22 | this.started = false;
23 | }
24 | }
25 |
26 | scrollToElement(element) {
27 | element.scrollIntoView();
28 | }
29 |
30 | scrollToPosition({ x, y }) {
31 | window.scrollTo(x, y);
32 | }
33 |
34 | // Private
35 | updatePosition(position) {
36 | this.delegate.scrollPositionChanged(position);
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/turbo/snapshot-cache.js:
--------------------------------------------------------------------------------
1 | export class SnapshotCache
2 | {
3 | constructor(size) {
4 | this.keys = [];
5 | this.snapshots = {};
6 | this.size = size;
7 | }
8 |
9 | has(location) {
10 | return location.toCacheKey() in this.snapshots;
11 | }
12 |
13 | get(location) {
14 | if (this.has(location)) {
15 | const snapshot = this.read(location);
16 | this.touch(location);
17 | return snapshot;
18 | }
19 | }
20 |
21 | put(location, snapshot) {
22 | this.write(location, snapshot);
23 | this.touch(location);
24 | return snapshot;
25 | }
26 |
27 | // Private
28 | read(location) {
29 | return this.snapshots[location.toCacheKey()];
30 | }
31 |
32 | write(location, snapshot) {
33 | this.snapshots[location.toCacheKey()] = snapshot;
34 | }
35 |
36 | touch(location) {
37 | const key = location.toCacheKey();
38 | const index = this.keys.indexOf(key);
39 | if (index > -1)
40 | this.keys.splice(index, 1);
41 | this.keys.unshift(key);
42 | this.trim();
43 | }
44 |
45 | trim() {
46 | for (const key of this.keys.splice(this.size)) {
47 | delete this.snapshots[key];
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/turbo/snapshot-renderer.js:
--------------------------------------------------------------------------------
1 | import { Renderer } from "./renderer";
2 | import { array } from "../util";
3 |
4 | export class SnapshotRenderer extends Renderer
5 | {
6 | constructor(delegate, currentSnapshot, newSnapshot, isPreview) {
7 | super();
8 | this.delegate = delegate;
9 | this.currentSnapshot = currentSnapshot;
10 | this.currentHeadDetails = currentSnapshot.headDetails;
11 | this.newSnapshot = newSnapshot;
12 | this.newHeadDetails = newSnapshot.headDetails;
13 | this.newBody = newSnapshot.bodyElement;
14 | this.isPreview = isPreview;
15 | }
16 |
17 | static render(delegate, callback, currentSnapshot, newSnapshot, isPreview) {
18 | return new this(delegate, currentSnapshot, newSnapshot, isPreview).render(callback);
19 | }
20 |
21 | render(callback) {
22 | if (this.shouldRender()) {
23 | this.mergeHead();
24 | this.renderView(() => {
25 | this.replaceBody();
26 | if (!this.isPreview) {
27 | this.focusFirstAutofocusableElement();
28 | }
29 | callback();
30 | });
31 | }
32 | else {
33 | this.invalidateView();
34 | }
35 | }
36 |
37 | mergeHead() {
38 | this.copyNewHeadStylesheetElements();
39 | this.copyNewHeadScriptElements();
40 | this.removeCurrentHeadProvisionalElements();
41 | this.copyNewHeadProvisionalElements();
42 | }
43 |
44 | replaceBody() {
45 | const placeholders = this.relocateCurrentBodyPermanentElements();
46 | this.activateNewBodyScriptElements();
47 | this.assignNewBody();
48 | this.replacePlaceholderElementsWithClonedPermanentElements(placeholders);
49 | }
50 |
51 | shouldRender() {
52 | return this.currentSnapshot.isEnabled() && this.newSnapshot.isVisitable() && this.trackedElementsAreIdentical();
53 | }
54 |
55 | trackedElementsAreIdentical() {
56 | return this.currentHeadDetails.getTrackedElementSignature() == this.newHeadDetails.getTrackedElementSignature();
57 | }
58 |
59 | copyNewHeadStylesheetElements() {
60 | for (const element of this.getNewHeadStylesheetElements()) {
61 | document.head.appendChild(element);
62 | }
63 | }
64 |
65 | copyNewHeadScriptElements() {
66 | for (const element of this.getNewHeadScriptElements()) {
67 | document.head.appendChild(
68 | this.bindPendingAssetLoadedEventOnce(
69 | this.createScriptElement(element)
70 | )
71 | );
72 | }
73 | }
74 |
75 | bindPendingAssetLoadedEventOnce(element) {
76 | if (!element.hasAttribute('src')) {
77 | return element;
78 | }
79 |
80 | var self = this,
81 | loadEvent = function() {
82 | self.delegate.decrementPendingAsset();
83 | element.removeEventListener('load', loadEvent);
84 | };
85 |
86 | element.addEventListener('load', loadEvent);
87 | this.delegate.incrementPendingAsset();
88 | return element;
89 | }
90 |
91 | removeCurrentHeadProvisionalElements() {
92 | for (const element of this.getCurrentHeadProvisionalElements()) {
93 | document.head.removeChild(element);
94 | }
95 | }
96 |
97 | copyNewHeadProvisionalElements() {
98 | for (const element of this.getNewHeadProvisionalElements()) {
99 | document.head.appendChild(element);
100 | }
101 | }
102 |
103 | relocateCurrentBodyPermanentElements() {
104 | return this.getCurrentBodyPermanentElements().reduce((placeholders, permanentElement) => {
105 | const newElement = this.newSnapshot.getPermanentElementById(permanentElement.id);
106 | if (newElement) {
107 | const placeholder = createPlaceholderForPermanentElement(permanentElement);
108 | replaceElementWithElement(permanentElement, placeholder.element);
109 | replaceElementWithElement(newElement, permanentElement);
110 | return [...placeholders, placeholder];
111 | }
112 | else {
113 | return placeholders;
114 | }
115 | }, []);
116 | }
117 |
118 | replacePlaceholderElementsWithClonedPermanentElements(placeholders) {
119 | for (const { element, permanentElement } of placeholders) {
120 | const clonedElement = permanentElement.cloneNode(true);
121 | replaceElementWithElement(element, clonedElement);
122 | }
123 | }
124 |
125 | activateNewBodyScriptElements() {
126 | for (const inertScriptElement of this.getNewBodyScriptElements()) {
127 | const activatedScriptElement = this.createScriptElement(inertScriptElement);
128 | replaceElementWithElement(inertScriptElement, activatedScriptElement);
129 | }
130 | }
131 |
132 | assignNewBody() {
133 | replaceElementWithElement(document.body, this.newBody);
134 | }
135 |
136 | focusFirstAutofocusableElement() {
137 | const element = this.newSnapshot.findFirstAutofocusableElement();
138 | if (elementIsFocusable(element)) {
139 | element.focus();
140 | }
141 | }
142 |
143 | getNewHeadStylesheetElements() {
144 | return this.newHeadDetails.getStylesheetElementsNotInDetails(this.currentHeadDetails);
145 | }
146 |
147 | getNewHeadScriptElements() {
148 | return this.newHeadDetails.getScriptElementsNotInDetails(this.currentHeadDetails);
149 | }
150 |
151 | getCurrentHeadProvisionalElements() {
152 | return this.currentHeadDetails.getProvisionalElements();
153 | }
154 |
155 | getNewHeadProvisionalElements() {
156 | return this.newHeadDetails.getProvisionalElements();
157 | }
158 |
159 | getCurrentBodyPermanentElements() {
160 | return this.currentSnapshot.getPermanentElementsPresentInSnapshot(this.newSnapshot);
161 | }
162 |
163 | getNewBodyScriptElements() {
164 | return array(this.newBody.querySelectorAll('script'));
165 | }
166 | }
167 |
168 | function createPlaceholderForPermanentElement(permanentElement) {
169 | const element = document.createElement('meta');
170 | element.setAttribute('name', 'turbo-permanent-placeholder');
171 | element.setAttribute('content', permanentElement.id);
172 | return { element, permanentElement };
173 | }
174 |
175 | function replaceElementWithElement(fromElement, toElement) {
176 | const parentElement = fromElement.parentElement;
177 | if (parentElement) {
178 | return parentElement.replaceChild(toElement, fromElement);
179 | }
180 | }
181 |
182 | function elementIsFocusable(element) {
183 | return element && typeof element.focus == 'function';
184 | }
185 |
--------------------------------------------------------------------------------
/src/turbo/snapshot.js:
--------------------------------------------------------------------------------
1 | import { HeadDetails } from "./head-details";
2 | import { Location } from "./location";
3 | import { array } from "../util";
4 |
5 | export class Snapshot
6 | {
7 | constructor(headDetails, bodyElement) {
8 | this.headDetails = headDetails;
9 | this.bodyElement = bodyElement;
10 | }
11 |
12 | static wrap(value) {
13 | if (value instanceof this) {
14 | return value;
15 | }
16 | else if (typeof value == 'string') {
17 | return this.fromHTMLString(value);
18 | }
19 | else {
20 | return this.fromHTMLElement(value);
21 | }
22 | }
23 |
24 | static fromHTMLString(html) {
25 | const element = document.createElement('html');
26 | element.innerHTML = html;
27 | return this.fromHTMLElement(element);
28 | }
29 |
30 | static fromHTMLElement(htmlElement) {
31 | const headElement = htmlElement.querySelector('head');
32 | const bodyElement = htmlElement.querySelector('body') || document.createElement('body');
33 | const headDetails = HeadDetails.fromHeadElement(headElement);
34 | return new this(headDetails, bodyElement);
35 | }
36 |
37 | clone() {
38 | return new Snapshot(this.headDetails, this.bodyElement.cloneNode(true));
39 | }
40 |
41 | getRootLocation() {
42 | const root = this.getSetting('root', '/');
43 | return new Location(root);
44 | }
45 |
46 | getCacheControlValue() {
47 | return this.getSetting('cache-control');
48 | }
49 |
50 | getElementForAnchor(anchor) {
51 | try {
52 | return this.bodyElement.querySelector(`[id='${anchor}'], a[name='${anchor}']`);
53 | }
54 | catch (e) {
55 | return null;
56 | }
57 | }
58 |
59 | getPermanentElements() {
60 | return array(this.bodyElement.querySelectorAll('[id][data-turbo-permanent]'));
61 | }
62 |
63 | getPermanentElementById(id) {
64 | return this.bodyElement.querySelector(`#${id}[data-turbo-permanent]`);
65 | }
66 |
67 | getPermanentElementsPresentInSnapshot(snapshot) {
68 | return this.getPermanentElements().filter(({ id }) => snapshot.getPermanentElementById(id));
69 | }
70 |
71 | findFirstAutofocusableElement() {
72 | return this.bodyElement.querySelector('[autofocus]');
73 | }
74 |
75 | hasAnchor(anchor) {
76 | return this.getElementForAnchor(anchor) != null;
77 | }
78 |
79 | isPreviewable() {
80 | return this.getCacheControlValue() != 'no-preview';
81 | }
82 |
83 | isCacheable() {
84 | return this.getCacheControlValue() != 'no-cache';
85 | }
86 |
87 | isNativeError() {
88 | return this.getSetting('visit-control', false) != false;
89 | }
90 |
91 | isEnabled() {
92 | return this.getSetting('visit-control') != 'disable';
93 | }
94 |
95 | isVisitable() {
96 | return this.isEnabled() && this.getSetting('visit-control') != 'reload';
97 | }
98 |
99 | getSetting(name, defaultValue) {
100 | const value = this.headDetails.getMetaValue(`turbo-${name}`);
101 | return value == null ? defaultValue : value;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/turbo/view.js:
--------------------------------------------------------------------------------
1 | import { ErrorRenderer } from "./error-renderer";
2 | import { Snapshot } from "./snapshot";
3 | import { SnapshotRenderer } from "./snapshot-renderer";
4 |
5 | export class View
6 | {
7 | constructor(delegate) {
8 | this.htmlElement = document.documentElement;
9 | this.delegate = delegate;
10 | }
11 |
12 | getRootLocation() {
13 | return this.getSnapshot().getRootLocation();
14 | }
15 |
16 | getElementForAnchor(anchor) {
17 | return this.getSnapshot().getElementForAnchor(anchor);
18 | }
19 |
20 | getSnapshot() {
21 | return Snapshot.fromHTMLElement(this.htmlElement);
22 | }
23 |
24 | render({ snapshot, error, isPreview }, callback) {
25 | this.markAsPreview(isPreview);
26 | if (snapshot) {
27 | this.renderSnapshot(snapshot, isPreview, callback);
28 | }
29 | else {
30 | this.renderError(error, callback);
31 | }
32 | }
33 |
34 | // Private
35 | markAsPreview(isPreview) {
36 | if (isPreview) {
37 | this.htmlElement.setAttribute('data-turbo-preview', '');
38 | }
39 | else {
40 | this.htmlElement.removeAttribute('data-turbo-preview');
41 | }
42 | }
43 |
44 | renderSnapshot(snapshot, isPreview, callback) {
45 | SnapshotRenderer.render(this.delegate, callback, this.getSnapshot(), snapshot, isPreview || false);
46 | }
47 |
48 | renderError(error, callback) {
49 | ErrorRenderer.render(this.delegate, callback, error || '');
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/turbo/visit.js:
--------------------------------------------------------------------------------
1 | import { HttpRequest } from "../util/http-request";
2 | import { Location } from "./location";
3 | import { Snapshot } from "./snapshot";
4 | import { uuid } from "../util";
5 |
6 | export var TimingMetric = {
7 | visitStart: 'visitStart',
8 | requestStart: 'requestStart',
9 | requestEnd: 'requestEnd',
10 | visitEnd: 'visitEnd'
11 | };
12 |
13 | export var VisitState = {
14 | initialized: 'initialized',
15 | started: 'started',
16 | canceled: 'canceled',
17 | failed: 'failed',
18 | completed: 'completed'
19 | };
20 |
21 | export class Visit
22 | {
23 | constructor(controller, location, action, restorationIdentifier = uuid()) {
24 | this.identifier = uuid();
25 | this.timingMetrics = {};
26 | this.followedRedirect = false;
27 | this.historyChanged = false;
28 | this.progress = 0;
29 | this.scrolled = false;
30 | this.snapshotCached = action === 'swap';
31 | this.state = VisitState.initialized;
32 |
33 | // Scrolling
34 | this.performScroll = () => {
35 | if (!this.scrolled) {
36 | if (this.action == 'restore') {
37 | this.scrollToRestoredPosition() || this.scrollToTop();
38 | }
39 | else {
40 | this.scrollToAnchor() || this.scrollToTop();
41 | }
42 | this.scrolled = true;
43 | }
44 | };
45 |
46 | this.controller = controller;
47 | this.location = location;
48 | this.action = action;
49 | this.adapter = controller.adapter;
50 | this.restorationIdentifier = restorationIdentifier;
51 | this.isSamePage = this.locationChangeIsSamePage();
52 | }
53 |
54 | start() {
55 | if (this.state == VisitState.initialized) {
56 | this.recordTimingMetric(TimingMetric.visitStart);
57 | this.state = VisitState.started;
58 | this.adapter.visitStarted(this);
59 | }
60 | }
61 |
62 | cancel() {
63 | if (this.state == VisitState.started) {
64 | if (this.request) {
65 | this.request.abort();
66 | }
67 |
68 | this.cancelRender();
69 | this.state = VisitState.canceled;
70 | }
71 | }
72 |
73 | complete() {
74 | if (this.state == VisitState.started) {
75 | this.recordTimingMetric(TimingMetric.visitEnd);
76 | this.state = VisitState.completed;
77 | this.adapter.visitCompleted(this);
78 | this.controller.visitCompleted(this);
79 | }
80 | }
81 |
82 | fail() {
83 | if (this.state == VisitState.started) {
84 | this.state = VisitState.failed;
85 | this.adapter.visitFailed(this);
86 | }
87 | }
88 |
89 | changeHistory() {
90 | if (!this.historyChanged) {
91 | const actionForHistory = this.location.isEqualTo(this.referrer) ? 'replace' : this.action;
92 | const method = this.getHistoryMethodForAction(actionForHistory);
93 | method.call(this.controller, this.location, this.restorationIdentifier);
94 | this.historyChanged = true;
95 | }
96 | }
97 |
98 | issueRequest() {
99 | if (this.shouldIssueRequest() && !this.request) {
100 | const url = Location.wrap(this.location).absoluteURL;
101 | const options = {
102 | method: 'GET',
103 | headers: {},
104 | htmlOnly: true,
105 | timeout: 240
106 | };
107 |
108 | options.headers['Accept'] = 'text/html, application/xhtml+xml';
109 | options.headers['X-PJAX'] = 1;
110 |
111 | if (this.hasCachedSnapshot()) {
112 | options.headers['X-PJAX-CACHED'] = 1;
113 | }
114 |
115 | if (this.referrer) {
116 | options.headers['X-PJAX-REFERRER'] = Location.wrap(this.referrer).absoluteURL;
117 | }
118 |
119 | this.progress = 0;
120 | this.request = new HttpRequest(this, url, options);
121 | this.request.send();
122 | }
123 | }
124 |
125 | getCachedSnapshot() {
126 | const snapshot = this.controller.getCachedSnapshotForLocation(this.location);
127 | if (snapshot && (!this.location.anchor || snapshot.hasAnchor(this.location.anchor))) {
128 | if (this.action == 'restore' || snapshot.isPreviewable()) {
129 | return snapshot;
130 | }
131 | }
132 | }
133 |
134 | hasCachedSnapshot() {
135 | return this.getCachedSnapshot() != null;
136 | }
137 |
138 | loadCachedSnapshot() {
139 | const snapshot = this.getCachedSnapshot();
140 | if (snapshot) {
141 | const isPreview = this.shouldIssueRequest();
142 |
143 | this.render(() => {
144 | this.cacheSnapshot();
145 | if (this.isSamePage) {
146 | this.performScroll();
147 | this.adapter.visitRendered(this);
148 | }
149 | else {
150 | this.controller.render({ snapshot, isPreview }, this.performScroll);
151 | this.adapter.visitRendered(this);
152 | if (!isPreview) {
153 | this.complete();
154 | }
155 | }
156 | });
157 | }
158 | }
159 |
160 | loadResponse() {
161 | const { request, response } = this;
162 | if (request && response) {
163 | this.render(() => {
164 | const snapshot = Snapshot.fromHTMLString(response);
165 |
166 | this.cacheSnapshot();
167 | if (request.failed && !snapshot.isNativeError()) {
168 | this.controller.render({ error: response }, this.performScroll);
169 | this.adapter.visitRendered(this);
170 | this.fail();
171 | }
172 | else {
173 | this.controller.render({ snapshot }, this.performScroll);
174 | this.adapter.visitRendered(this);
175 | this.complete();
176 | }
177 | });
178 | }
179 | }
180 |
181 | followRedirect() {
182 | if (this.redirectedToLocation && !this.followedRedirect) {
183 | this.location = this.redirectedToLocation;
184 | this.controller.replaceHistoryWithLocationAndRestorationIdentifier(this.redirectedToLocation, this.restorationIdentifier);
185 | this.followedRedirect = true;
186 | }
187 | }
188 |
189 | goToSamePageAnchor() {
190 | if (this.isSamePage) {
191 | this.render(() => {
192 | this.cacheSnapshot();
193 | this.performScroll();
194 | this.adapter.visitRendered(this);
195 | });
196 | }
197 | }
198 |
199 | // HTTP request delegate
200 | requestStarted() {
201 | this.recordTimingMetric(TimingMetric.requestStart);
202 | this.adapter.visitRequestStarted(this);
203 | }
204 |
205 | requestProgressed(progress) {
206 | this.progress = progress;
207 | if (this.adapter.visitRequestProgressed) {
208 | this.adapter.visitRequestProgressed(this);
209 | }
210 | }
211 |
212 | requestCompletedWithResponse(response, statusCode, redirectedToLocation) {
213 | this.response = response;
214 | this.redirectedToLocation = Location.wrap(redirectedToLocation);
215 | this.adapter.visitRequestCompleted(this);
216 | }
217 |
218 | requestFailedWithStatusCode(statusCode, response) {
219 | this.response = response;
220 | this.adapter.visitRequestFailedWithStatusCode(this, statusCode);
221 | }
222 |
223 | requestFinished() {
224 | this.recordTimingMetric(TimingMetric.requestEnd);
225 | this.adapter.visitRequestFinished(this);
226 | }
227 |
228 | scrollToRestoredPosition() {
229 | const position = this.restorationData ? this.restorationData.scrollPosition : undefined;
230 | if (position) {
231 | this.controller.scrollToPosition(position);
232 | return true;
233 | }
234 | }
235 |
236 | scrollToAnchor() {
237 | if (this.location.anchor != null) {
238 | this.controller.scrollToAnchor(this.location.anchor);
239 | return true;
240 | }
241 | }
242 |
243 | scrollToTop() {
244 | this.controller.scrollToPosition({ x: 0, y: 0 });
245 | }
246 |
247 | // Instrumentation
248 | recordTimingMetric(metric) {
249 | this.timingMetrics[metric] = new Date().getTime();
250 | }
251 |
252 | getTimingMetrics() {
253 | return Object.assign({}, this.timingMetrics);
254 | }
255 |
256 | // Private
257 | getHistoryMethodForAction(action) {
258 | switch (action) {
259 | case 'swap':
260 | case 'replace':
261 | return this.controller.replaceHistoryWithLocationAndRestorationIdentifier;
262 |
263 | case 'advance':
264 | case 'restore':
265 | return this.controller.pushHistoryWithLocationAndRestorationIdentifier;
266 | }
267 | }
268 |
269 | shouldIssueRequest() {
270 | if (this.action == 'restore') {
271 | return !this.hasCachedSnapshot();
272 | }
273 | else if (this.isSamePage) {
274 | return false;
275 | }
276 | else {
277 | return true;
278 | }
279 | }
280 |
281 | locationChangeIsSamePage() {
282 | if (this.action == 'swap') {
283 | return true;
284 | }
285 |
286 | const lastLocation = this.action == 'restore' && this.controller.lastRenderedLocation;
287 | return this.controller.locationIsSamePageAnchor(lastLocation || this.location);
288 | }
289 |
290 | cacheSnapshot() {
291 | if (!this.snapshotCached) {
292 | this.controller.cacheSnapshot();
293 | this.snapshotCached = true;
294 | }
295 | }
296 |
297 | render(callback) {
298 | this.cancelRender();
299 | this.frame = requestAnimationFrame(() => {
300 | delete this.frame;
301 | callback.call(this);
302 | });
303 | }
304 |
305 | cancelRender() {
306 | if (this.frame) {
307 | cancelAnimationFrame(this.frame);
308 | delete this.frame;
309 | }
310 | }
311 | }
312 |
--------------------------------------------------------------------------------
/src/util/deferred.js:
--------------------------------------------------------------------------------
1 | export var DeferredState = {
2 | pending: 'pending',
3 | rejected: 'rejected',
4 | resolved: 'resolved'
5 | }
6 |
7 | export class Deferred
8 | {
9 | constructor(options) {
10 | this.options = options || {};
11 | this.stateStr = DeferredState.pending;
12 |
13 | this.successFuncs = [];
14 | this.failureFuncs = [];
15 | this.progressFuncs = [];
16 |
17 | this.resolveArgs = [];
18 | this.rejectArgs = [];
19 | this.progressArgs = [];
20 |
21 | this.isProgressNotified = false;
22 | }
23 |
24 | // Public
25 | resolve() {
26 | if (this.stateStr === DeferredState.pending) {
27 | this.resolveArgs = arguments;
28 | this.callFunction.call(this, this.successFuncs, this.resolveArgs);
29 | this.stateStr = DeferredState.resolved;
30 | }
31 |
32 | return this;
33 | }
34 |
35 | reject() {
36 | if (this.stateStr === DeferredState.pending) {
37 | this.rejectArgs = arguments;
38 | this.callFunction.call(this, this.failureFuncs, this.rejectArgs);
39 | this.stateStr = DeferredState.rejected;
40 | }
41 |
42 | return this;
43 | }
44 |
45 | notify() {
46 | if (this.stateStr === DeferredState.pending) {
47 | this.progressArgs = arguments;
48 | this.callFunction.call(this, this.progressFuncs, this.progressArgs);
49 | this.isProgressNotified = true;
50 | }
51 | return this;
52 | }
53 |
54 | abort() {
55 | this.options.delegate && this.options.delegate.abort();
56 | }
57 |
58 | done() {
59 | var argumentsArray = Array.prototype.slice.call(arguments);
60 | this.successFuncs = this.successFuncs.concat(argumentsArray);
61 |
62 | if (this.stateStr === DeferredState.resolved) {
63 | this.callFunction.call(this, argumentsArray, this.resolveArgs);
64 | }
65 |
66 | return this;
67 | }
68 |
69 | fail() {
70 | var argumentsArray = Array.prototype.slice.call(arguments);
71 | this.failureFuncs = this.failureFuncs.concat(argumentsArray);
72 |
73 | if (this.stateStr === DeferredState.rejected) {
74 | this.callFunction.call(this, argumentsArray, this.rejectArgs);
75 | }
76 |
77 | return this;
78 | }
79 |
80 | progress() {
81 | var argumentsArray = Array.prototype.slice.call(arguments);
82 | this.progressFuncs = this.progressFuncs.concat(argumentsArray);
83 |
84 | if (this.stateStr === DeferredState.pending && this.isProgressNotified) {
85 | this.callFunction.call(this, argumentsArray, this.progressArgs);
86 | }
87 |
88 | return this;
89 | }
90 |
91 | always() {
92 | var argumentsArray = Array.prototype.slice.call(arguments);
93 | this.successFuncs = this.successFuncs.concat(argumentsArray);
94 | this.failureFuncs = this.failureFuncs.concat(argumentsArray);
95 |
96 | if (this.stateStr !== DeferredState.pending) {
97 | this.callFunction.call(this, argumentsArray, this.resolveArgs || this.rejectArgs);
98 | }
99 |
100 | return this;
101 | }
102 |
103 | then() {
104 | var tempArgs = [];
105 | for (var index in arguments) {
106 | var itemToPush;
107 |
108 | if (Array.isArray(arguments[index])) {
109 | itemToPush = arguments[index];
110 | }
111 | else {
112 | itemToPush = [arguments[index]];
113 | }
114 |
115 | tempArgs.push(itemToPush);
116 | }
117 |
118 | this.done.apply(this, tempArgs[0]);
119 | this.fail.apply(this, tempArgs[1]);
120 | this.progress.apply(this, tempArgs[2]);
121 |
122 | return this;
123 | }
124 |
125 | promise() {
126 | var protectedNames = ['resolve', 'reject', 'promise', 'notify'];
127 | var result = {};
128 |
129 | for (var key in this) {
130 | if (protectedNames.indexOf(key) === -1) {
131 | result[key] = this[key];
132 | }
133 | }
134 |
135 | return result;
136 | }
137 |
138 | state() {
139 | if (arguments.length > 0) {
140 | stateStr = arguments[0];
141 | }
142 |
143 | return stateStr;
144 | }
145 |
146 | // Private
147 | callFunction(functionDefinitionArray, functionArgumentArray, options) {
148 | options = options || {};
149 | var scope = options.scope || this;
150 |
151 | for (var index in functionDefinitionArray) {
152 | var item = functionDefinitionArray[index];
153 | if (typeof(item) === 'function') {
154 | item.apply(scope, functionArgumentArray);
155 | }
156 | }
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/src/util/events.js:
--------------------------------------------------------------------------------
1 | import { dispatch } from "./index";
2 |
3 | /**
4 | * Constants
5 | */
6 | const namespaceRegex = /[^.]*(?=\..*)\.|.*/
7 | const stripNameRegex = /\..*/
8 | const stripUidRegex = /::\d+$/
9 | const eventRegistry = {} // Events storage
10 |
11 | let uidEvent = 1;
12 |
13 | const customEvents = {
14 | mouseenter: 'mouseover',
15 | mouseleave: 'mouseout'
16 | }
17 |
18 | const nativeEvents = new Set([
19 | 'click',
20 | 'dblclick',
21 | 'mouseup',
22 | 'mousedown',
23 | 'contextmenu',
24 | 'mousewheel',
25 | 'DOMMouseScroll',
26 | 'mouseover',
27 | 'mouseout',
28 | 'mousemove',
29 | 'selectstart',
30 | 'selectend',
31 | 'keydown',
32 | 'keypress',
33 | 'keyup',
34 | 'orientationchange',
35 | 'touchstart',
36 | 'touchmove',
37 | 'touchend',
38 | 'touchcancel',
39 | 'pointerdown',
40 | 'pointermove',
41 | 'pointerup',
42 | 'pointerleave',
43 | 'pointercancel',
44 | 'gesturestart',
45 | 'gesturechange',
46 | 'gestureend',
47 | 'focus',
48 | 'blur',
49 | 'change',
50 | 'reset',
51 | 'select',
52 | 'submit',
53 | 'focusin',
54 | 'focusout',
55 | 'load',
56 | 'unload',
57 | 'beforeunload',
58 | 'resize',
59 | 'move',
60 | 'DOMContentLoaded',
61 | 'readystatechange',
62 | 'error',
63 | 'abort',
64 | 'scroll'
65 | ]);
66 |
67 | export class Events
68 | {
69 | static on(element, event, handler, delegationFunction, options) {
70 | addHandler(element, event, handler, delegationFunction, options, false);
71 | }
72 |
73 | static one(element, event, handler, delegationFunction, options) {
74 | addHandler(element, event, handler, delegationFunction, options, true);
75 | }
76 |
77 | static off(element, originalTypeEvent, handler, delegationFunction, options) {
78 | if (typeof originalTypeEvent !== 'string' || !element) {
79 | return;
80 | }
81 |
82 | const [isDelegated, callable, typeEvent, opts] = normalizeParameters(originalTypeEvent, handler, delegationFunction, options);
83 | const inNamespace = typeEvent !== originalTypeEvent;
84 | const events = getElementEvents(element);
85 | const storeElementEvent = events[typeEvent] || {};
86 | const isNamespace = originalTypeEvent.startsWith('.');
87 |
88 | if (typeof callable !== 'undefined') {
89 | // Simplest case: handler is passed, remove that listener ONLY.
90 | if (!storeElementEvent) {
91 | return;
92 | }
93 |
94 | removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null, opts);
95 | return;
96 | }
97 |
98 | if (isNamespace) {
99 | for (const elementEvent of Object.keys(events)) {
100 | removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1));
101 | }
102 | }
103 |
104 | for (const keyHandlers of Object.keys(storeElementEvent)) {
105 | const handlerKey = keyHandlers.replace(stripUidRegex, '');
106 |
107 | if (!inNamespace || originalTypeEvent.includes(handlerKey)) {
108 | const event = storeElementEvent[keyHandlers];
109 | removeHandler(element, events, typeEvent, event.callable, event.delegationSelector, opts);
110 | }
111 | }
112 | }
113 |
114 | static dispatch(eventName, { target = document, detail = {}, bubbles = true, cancelable = true } = {}) {
115 | return dispatch(eventName, { target, detail, bubbles, cancelable });
116 | }
117 | }
118 |
119 | /**
120 | * Private methods
121 | */
122 | function makeEventUid(element, uid) {
123 | return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++;
124 | }
125 |
126 | function getElementEvents(element) {
127 | const uid = makeEventUid(element);
128 |
129 | element.uidEvent = uid;
130 | eventRegistry[uid] = eventRegistry[uid] || {};
131 |
132 | return eventRegistry[uid];
133 | }
134 |
135 | function findHandler(events, callable, delegationSelector = null) {
136 | return Object.values(events)
137 | .find(event => event.callable === callable && event.delegationSelector === delegationSelector);
138 | }
139 |
140 | function normalizeParameters(originalTypeEvent, handler, delegationFunction, options) {
141 | const isDelegated = typeof handler === 'string';
142 | const callable = isDelegated ? delegationFunction : handler;
143 | const opts = isDelegated ? options : delegationFunction;
144 | let typeEvent = getTypeEvent(originalTypeEvent);
145 |
146 | if (!nativeEvents.has(typeEvent)) {
147 | typeEvent = originalTypeEvent;
148 | }
149 |
150 | return [isDelegated, callable, typeEvent, opts];
151 | }
152 |
153 | function addHandler(element, originalTypeEvent, handler, delegationFunction, options, oneOff) {
154 | if (typeof originalTypeEvent !== 'string' || !element) {
155 | return;
156 | }
157 |
158 | let [isDelegated, callable, typeEvent, opts] = normalizeParameters(originalTypeEvent, handler, delegationFunction, options);
159 |
160 | // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position
161 | // this prevents the handler from being dispatched the same way as mouseover or mouseout does
162 | if (originalTypeEvent in customEvents) {
163 | const wrapFunction = fn => {
164 | return function (event) {
165 | if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) {
166 | return fn.call(this, event);
167 | }
168 | }
169 | }
170 |
171 | callable = wrapFunction(callable);
172 | }
173 |
174 | const events = getElementEvents(element);
175 | const handlers = events[typeEvent] || (events[typeEvent] = {});
176 | const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null);
177 |
178 | if (previousFunction) {
179 | previousFunction.oneOff = previousFunction.oneOff && oneOff;
180 | return;
181 | }
182 |
183 | const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, ''));
184 | const fn = isDelegated
185 | ? internalDelegationHandler(element, handler, callable)
186 | : internalHandler(element, callable);
187 |
188 | fn.delegationSelector = isDelegated ? handler : null;
189 | fn.callable = callable;
190 | fn.oneOff = oneOff;
191 | fn.uidEvent = uid;
192 | handlers[uid] = fn;
193 |
194 | element.addEventListener(typeEvent, fn, opts);
195 | }
196 |
197 | function removeHandler(element, events, typeEvent, handler, delegationSelector, options) {
198 | const fn = findHandler(events[typeEvent], handler, delegationSelector);
199 |
200 | if (!fn) {
201 | return;
202 | }
203 |
204 | element.removeEventListener(typeEvent, fn, options);
205 | delete events[typeEvent][fn.uidEvent];
206 | }
207 |
208 | function internalHandler(element, fn) {
209 | return function handler(event) {
210 | event.delegateTarget = element;
211 |
212 | if (handler.oneOff) {
213 | Events.off(element, event.type, fn);
214 | }
215 |
216 | return fn.apply(element, [event]);
217 | }
218 | }
219 |
220 | function internalDelegationHandler(element, selector, fn) {
221 | return function handler(event) {
222 | const domElements = element.querySelectorAll(selector);
223 |
224 | for (let { target } = event; target && target !== this; target = target.parentNode) {
225 | for (const domElement of domElements) {
226 | if (domElement !== target) {
227 | continue;
228 | }
229 |
230 | event.delegateTarget = target;
231 |
232 | if (handler.oneOff) {
233 | Events.off(element, event.type, selector, fn);
234 | }
235 |
236 | return fn.apply(target, [event]);
237 | }
238 | }
239 | }
240 | }
241 |
242 | function removeNamespacedHandlers(element, events, typeEvent, namespace) {
243 | const storeElementEvent = events[typeEvent] || {};
244 |
245 | for (const handlerKey of Object.keys(storeElementEvent)) {
246 | if (handlerKey.includes(namespace)) {
247 | const event = storeElementEvent[handlerKey];
248 | removeHandler(element, events, typeEvent, event.callable, event.delegationSelector);
249 | }
250 | }
251 | }
252 |
253 | // Allow to get the native events from namespaced events ('click.bs.button' --> 'click')
254 | function getTypeEvent(event) {
255 | event = event.replace(stripNameRegex, '');
256 | return customEvents[event] || event;
257 | }
258 |
--------------------------------------------------------------------------------
/src/util/form-serializer.js:
--------------------------------------------------------------------------------
1 | // FormSerializer serializes input elements to JSON
2 | export class FormSerializer
3 | {
4 | // Public
5 | static assignToObj(obj, name, value) {
6 | (new FormSerializer).assignObjectInternal(obj, name, value);
7 | }
8 |
9 | static serializeJSON(element) {
10 | return (new FormSerializer).parseContainer(element);
11 | }
12 |
13 | // Private
14 | parseContainer(element) {
15 | let jsonData = {};
16 | element.querySelectorAll('input, textarea, select').forEach((field) => {
17 | if (!field.name || field.disabled || ['file', 'reset', 'submit', 'button'].indexOf(field.type) > -1) {
18 | return;
19 | }
20 |
21 | if (['checkbox', 'radio'].indexOf(field.type) > -1 && !field.checked) {
22 | return;
23 | }
24 |
25 | if (field.type === 'select-multiple') {
26 | var arr = [];
27 | Array.from(field.options).forEach(function(option) {
28 | if (option.selected) {
29 | arr.push({
30 | name: field.name,
31 | value: option.value
32 | });
33 | }
34 | });
35 | this.assignObjectInternal(jsonData, field.name, arr);
36 | return;
37 | }
38 |
39 | this.assignObjectInternal(jsonData, field.name, field.value);
40 | });
41 |
42 | return jsonData;
43 | }
44 |
45 | assignObjectInternal(obj, fieldName, fieldValue) {
46 | this.assignObjectNested(
47 | obj,
48 | this.nameToArray(fieldName),
49 | fieldValue,
50 | fieldName.endsWith('[]')
51 | );
52 | }
53 |
54 | assignObjectNested(obj, fieldArr, fieldValue, isArray) {
55 | var currentTarget = obj,
56 | lastIndex = fieldArr.length - 1;
57 |
58 | fieldArr.forEach(function(prop, index) {
59 | if (isArray && index === lastIndex) {
60 | if (!Array.isArray(currentTarget[prop])) {
61 | currentTarget[prop] = [];
62 | }
63 |
64 | currentTarget[prop].push(fieldValue);
65 | }
66 | else {
67 | if (currentTarget[prop] === undefined || currentTarget[prop].constructor !== {}.constructor) {
68 | currentTarget[prop] = {};
69 | }
70 |
71 | if (index === lastIndex) {
72 | currentTarget[prop] = fieldValue;
73 | }
74 |
75 | currentTarget = currentTarget[prop];
76 | }
77 | });
78 | }
79 |
80 | nameToArray(fieldName) {
81 | var expression = /([^\]\[]+)/g,
82 | elements = [],
83 | searchResult;
84 |
85 | while ((searchResult = expression.exec(fieldName))) {
86 | elements.push(searchResult[0]);
87 | }
88 |
89 | return elements;
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/util/http-request.js:
--------------------------------------------------------------------------------
1 | import { Events } from "./events";
2 |
3 | export var SystemStatusCode = {
4 | networkFailure: 0,
5 | timeoutFailure: -1,
6 | contentTypeMismatch: -2,
7 | userAborted: -3
8 | }
9 |
10 | export class HttpRequest
11 | {
12 | constructor(delegate, url, options) {
13 | this.failed = false;
14 | this.progress = 0;
15 | this.sent = false;
16 |
17 | this.delegate = delegate;
18 | this.url = url;
19 | this.options = options;
20 |
21 | this.headers = options.headers || {};
22 | this.method = options.method || 'GET';
23 | this.responseType = options.responseType || '';
24 | this.data = options.data;
25 | this.timeout = options.timeout || 0;
26 |
27 | // XMLHttpRequest events
28 | this.requestProgressed = (event) => {
29 | if (event.lengthComputable) {
30 | this.setProgress(event.loaded / event.total);
31 | }
32 | };
33 |
34 | this.requestLoaded = () => {
35 | this.endRequest(xhr => {
36 | this.processResponseData(xhr, (xhr, data) => {
37 | const contentType = xhr.getResponseHeader('Content-Type');
38 | const responseData = contentTypeIsJSON(contentType) ? JSON.parse(data) : data;
39 |
40 | if (this.options.htmlOnly && !contentTypeIsHTML(contentType)) {
41 | this.failed = true;
42 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.contentTypeMismatch);
43 | return;
44 | }
45 |
46 | if (xhr.status >= 200 && xhr.status < 300) {
47 | this.delegate.requestCompletedWithResponse(responseData, xhr.status, contentResponseIsRedirect(xhr, this.url));
48 | }
49 | else {
50 | this.failed = true;
51 | this.delegate.requestFailedWithStatusCode(xhr.status, responseData);
52 | }
53 | });
54 | });
55 | };
56 |
57 | this.requestFailed = () => {
58 | this.endRequest(() => {
59 | this.failed = true;
60 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.networkFailure);
61 | });
62 | };
63 |
64 | this.requestTimedOut = () => {
65 | this.endRequest(() => {
66 | this.failed = true;
67 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.timeoutFailure);
68 | });
69 | };
70 |
71 | this.requestCanceled = () => {
72 | if (this.options.trackAbort) {
73 | this.endRequest(() => {
74 | this.failed = true;
75 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.userAborted);
76 | });
77 | }
78 | else {
79 | this.endRequest();
80 | }
81 | };
82 |
83 | this.createXHR();
84 | }
85 |
86 | send() {
87 | if (this.xhr && !this.sent) {
88 | this.notifyApplicationBeforeRequestStart();
89 | this.setProgress(0);
90 | this.xhr.send(this.data || null);
91 | this.sent = true;
92 | this.delegate.requestStarted();
93 | }
94 | }
95 |
96 | abort() {
97 | if (this.xhr && this.sent) {
98 | this.xhr.abort();
99 | }
100 | }
101 |
102 | // Application events
103 | notifyApplicationBeforeRequestStart() {
104 | Events.dispatch('ajax:request-start', { detail: { url: this.url, xhr: this.xhr }, cancelable: false });
105 | }
106 |
107 | notifyApplicationAfterRequestEnd() {
108 | Events.dispatch('ajax:request-end', { detail: { url: this.url, xhr: this.xhr }, cancelable: false });
109 | }
110 |
111 | // Private
112 | createXHR() {
113 | const xhr = this.xhr = new XMLHttpRequest;
114 | xhr.open(this.method, this.url, true);
115 | xhr.responseType = this.responseType;
116 |
117 | xhr.onprogress = this.requestProgressed;
118 | xhr.onload = this.requestLoaded;
119 | xhr.onerror = this.requestFailed;
120 | xhr.ontimeout = this.requestTimedOut;
121 | xhr.onabort = this.requestCanceled;
122 |
123 | if (this.timeout) {
124 | xhr.timeout = this.timeout * 1000;
125 | }
126 |
127 | for (var i in this.headers) {
128 | xhr.setRequestHeader(i, this.headers[i]);
129 | }
130 |
131 | return xhr;
132 | }
133 |
134 | endRequest(callback = () => { }) {
135 | if (this.xhr) {
136 | this.notifyApplicationAfterRequestEnd();
137 | callback(this.xhr);
138 | this.destroy();
139 | }
140 | }
141 |
142 | setProgress(progress) {
143 | this.progress = progress;
144 | this.delegate.requestProgressed(progress);
145 | }
146 |
147 | destroy() {
148 | this.setProgress(1);
149 | this.delegate.requestFinished();
150 | }
151 |
152 | processResponseData(xhr, callback) {
153 | if (this.responseType !== 'blob') {
154 | callback(xhr, xhr.responseText);
155 | return;
156 | }
157 |
158 | // Confirm response is a download
159 | const contentDisposition = xhr.getResponseHeader('Content-Disposition') || '';
160 | if (contentDisposition.indexOf('attachment') === 0 || contentDisposition.indexOf('inline') === 0) {
161 | callback(xhr, xhr.response);
162 | return;
163 | }
164 |
165 | // Convert blob to text
166 | const reader = new FileReader;
167 | reader.addEventListener('load', () => { callback(xhr, reader.result); });
168 | reader.readAsText(xhr.response);
169 | }
170 | }
171 |
172 | function contentResponseIsRedirect(xhr, url) {
173 | if (xhr.getResponseHeader('X-OCTOBER-LOCATION')) {
174 | return xhr.getResponseHeader('X-OCTOBER-LOCATION');
175 | }
176 |
177 | var anchorMatch = url.match(/^(.*)#/),
178 | wantUrl = anchorMatch ? anchorMatch[1] : url;
179 |
180 | return wantUrl !== xhr.responseURL ? xhr.responseURL : null;
181 | }
182 |
183 | function contentTypeIsHTML(contentType) {
184 | return (contentType || '').match(/^text\/html|^application\/xhtml\+xml/);
185 | }
186 |
187 | function contentTypeIsJSON(contentType) {
188 | return (contentType || '').includes('application/json');
189 | }
190 |
--------------------------------------------------------------------------------
/src/util/index.js:
--------------------------------------------------------------------------------
1 | export function dispatch(eventName, { target = document, detail = {}, bubbles = true, cancelable = true } = {}) {
2 | const event = new CustomEvent(eventName, { detail, bubbles, cancelable });
3 | target.dispatchEvent(event);
4 | return event;
5 | }
6 |
7 | export function defer(callback) {
8 | setTimeout(callback, 1);
9 | }
10 |
11 | export function unindent(strings, ...values) {
12 | const lines = trimLeft(interpolate(strings, values)).split("\n");
13 | const match = lines[0].match(/^\s+/);
14 | const indent = match ? match[0].length : 0;
15 | return lines.map(line => line.slice(indent)).join("\n");
16 | }
17 |
18 | function trimLeft(string) {
19 | return string.replace(/^\n/, "");
20 | }
21 |
22 | function interpolate(strings, values) {
23 | return strings.reduce((result, string, i) => {
24 | const value = values[i] == undefined ? "" : values[i];
25 | return result + string + value;
26 | }, "");
27 | }
28 |
29 | export function array(values) {
30 | return Array.prototype.slice.call(values);
31 | }
32 |
33 | export function uuid() {
34 | return Array.apply(null, { length: 36 }).map((_, i) => {
35 | if (i == 8 || i == 13 || i == 18 || i == 23) {
36 | return "-";
37 | }
38 | else if (i == 14) {
39 | return "4";
40 | }
41 | else if (i == 19) {
42 | return (Math.floor(Math.random() * 4) + 8).toString(16);
43 | }
44 | else {
45 | return Math.floor(Math.random() * 15).toString(16);
46 | }
47 | }).join("");
48 | }
49 |
--------------------------------------------------------------------------------
/src/util/json-parser.js:
--------------------------------------------------------------------------------
1 | // JsonParser serializes JS-syntax to JSON without using eval
2 | export class JsonParser
3 | {
4 | // Public
5 | static paramToObj(name, value) {
6 | if (value === undefined) {
7 | value = '';
8 | }
9 |
10 | if (typeof value === 'object') {
11 | return value;
12 | }
13 |
14 | if (value.charAt(0) !== '{') {
15 | value = "{" + value + "}";
16 | }
17 |
18 | try {
19 | return this.parseJSON(value);
20 | }
21 | catch (e) {
22 | throw new Error('Error parsing the ' + name + ' attribute value. ' + e);
23 | }
24 | }
25 |
26 | static parseJSON(json) {
27 | return JSON.parse((new JsonParser).parseString(json));
28 | }
29 |
30 | // Private
31 | parseString(str) {
32 | str = str.trim();
33 | if (!str.length) {
34 | throw new Error("Broken JSON object.");
35 | }
36 |
37 | var result = "";
38 |
39 | /*
40 | * the mistake ','
41 | */
42 | while (str && str[0] === ",") {
43 | str = str.substr(1);
44 | }
45 |
46 | /*
47 | * string
48 | */
49 | if (str[0] === "\"" || str[0] === "'") {
50 | if (str[str.length - 1] !== str[0]) {
51 | throw new Error("Invalid string JSON object.");
52 | }
53 |
54 | var body = "\"";
55 | for (var i = 1; i < str.length; i++) {
56 | if (str[i] === "\\") {
57 | if (str[i + 1] === "'") {
58 | body += str[i + 1]
59 | }
60 | else {
61 | body += str[i];
62 | body += str[i + 1];
63 | }
64 | i++;
65 | }
66 | else if (str[i] === str[0]) {
67 | body += "\"";
68 | return body
69 | }
70 | else if (str[i] === "\"") {
71 | body += "\\\""
72 | }
73 | else body += str[i];
74 | }
75 | throw new Error("Invalid string JSON object.");
76 | }
77 |
78 | /*
79 | * boolean
80 | */
81 | if (str === "true" || str === "false") {
82 | return str;
83 | }
84 |
85 | /*
86 | * null
87 | */
88 | if (str === "null") {
89 | return "null";
90 | }
91 |
92 | /*
93 | * number
94 | */
95 | var num = parseFloat(str);
96 | if (!isNaN(num)) {
97 | return num.toString();
98 | }
99 |
100 | /*
101 | * object
102 | */
103 | if (str[0] === "{") {
104 | var type = "needKey";
105 | var result = "{";
106 |
107 | for (var i = 1; i < str.length; i++) {
108 | if (this.isBlankChar(str[i])) {
109 | continue;
110 | }
111 | else if (type === "needKey" && (str[i] === "\"" || str[i] === "'")) {
112 | var key = this.parseKey(str, i + 1, str[i]);
113 | result += "\"" + key + "\"";
114 | i += key.length;
115 | i += 1;
116 | type = "afterKey";
117 | }
118 | else if (type === "needKey" && this.canBeKeyHead(str[i])) {
119 | var key = this.parseKey(str, i);
120 | result += "\"";
121 | result += key;
122 | result += "\"";
123 | i += key.length - 1;
124 | type = "afterKey";
125 | }
126 | else if (type === "afterKey" && str[i] === ":") {
127 | result += ":";
128 | type = ":";
129 | }
130 | else if (type === ":") {
131 | var body = this.getBody(str, i);
132 |
133 | i = i + body.originLength - 1;
134 | result += this.parseString(body.body);
135 |
136 | type = "afterBody";
137 | }
138 | else if (type === "afterBody" || type === "needKey") {
139 | var last = i;
140 | while (str[last] === "," || this.isBlankChar(str[last])) {
141 | last++;
142 | }
143 | if (str[last] === "}" && last === str.length - 1) {
144 | while (result[result.length - 1] === ",") {
145 | result = result.substr(0, result.length - 1);
146 | }
147 | result += "}";
148 | return result;
149 | }
150 | else if (last !== i && result !== "{") {
151 | result += ",";
152 | type = "needKey";
153 | i = last - 1;
154 | }
155 | }
156 | }
157 | throw new Error("Broken JSON object near " + result);
158 | }
159 |
160 | /*
161 | * array
162 | */
163 | if (str[0] === "[") {
164 | var result = "[";
165 | var type = "needBody";
166 | for (var i = 1; i < str.length; i++) {
167 | if (" " === str[i] || "\n" === str[i] || "\t" === str[i]) {
168 | continue;
169 | }
170 | else if (type === "needBody") {
171 | if (str[i] === ",") {
172 | result += "null,";
173 | continue;
174 | }
175 | if (str[i] === "]" && i === str.length - 1) {
176 | if (result[result.length - 1] === ",") result = result.substr(0, result.length - 1);
177 | result += "]";
178 | return result;
179 | }
180 |
181 | var body = this.getBody(str, i);
182 |
183 | i = i + body.originLength - 1;
184 | result += this.parseString(body.body);
185 |
186 | type = "afterBody";
187 | }
188 | else if (type === "afterBody") {
189 | if (str[i] === ",") {
190 | result += ",";
191 | type = "needBody";
192 |
193 | // deal with mistake ","
194 | while (str[i + 1] === "," || this.isBlankChar(str[i + 1])) {
195 | if (str[i + 1] === ",") result += "null,";
196 | i++;
197 | }
198 | }
199 | else if (str[i] === "]" && i === str.length - 1) {
200 | result += "]";
201 | return result;
202 | }
203 | }
204 | }
205 | throw new Error("Broken JSON array near " + result);
206 | }
207 | }
208 |
209 | parseKey(str, pos, quote) {
210 | var key = "";
211 | for (var i = pos; i < str.length; i++) {
212 | if (quote && quote === str[i]) {
213 | return key;
214 | }
215 | else if (!quote && (str[i] === " " || str[i] === ":")) {
216 | return key;
217 | }
218 |
219 | key += str[i];
220 |
221 | if (str[i] === "\\" && i + 1 < str.length) {
222 | key += str[i + 1];
223 | i++;
224 | }
225 | }
226 | throw new Error("Broken JSON syntax near " + key);
227 | }
228 |
229 | getBody(str, pos) {
230 | // parse string body
231 | if (str[pos] === "\"" || str[pos] === "'") {
232 | var body = str[pos];
233 | for (var i = pos + 1; i < str.length; i++) {
234 | if (str[i] === "\\") {
235 | body += str[i];
236 | if (i + 1 < str.length) body += str[i + 1];
237 | i++;
238 | }
239 | else if (str[i] === str[pos]) {
240 | body += str[pos];
241 | return {
242 | originLength: body.length,
243 | body: body
244 | };
245 | }
246 | else body += str[i];
247 | }
248 | throw new Error("Broken JSON string body near " + body);
249 | }
250 |
251 | // parse true / false
252 | if (str[pos] === "t") {
253 | if (str.indexOf("true", pos) === pos) {
254 | return {
255 | originLength: "true".length,
256 | body: "true"
257 | };
258 | }
259 | throw new Error("Broken JSON boolean body near " + str.substr(0, pos + 10));
260 | }
261 | if (str[pos] === "f") {
262 | if (str.indexOf("f", pos) === pos) {
263 | return {
264 | originLength: "false".length,
265 | body: "false"
266 | };
267 | }
268 | throw new Error("Broken JSON boolean body near " + str.substr(0, pos + 10));
269 | }
270 |
271 | // parse null
272 | if (str[pos] === "n") {
273 | if (str.indexOf("null", pos) === pos) {
274 | return {
275 | originLength: "null".length,
276 | body: "null"
277 | };
278 | }
279 | throw new Error("Broken JSON boolean body near " + str.substr(0, pos + 10));
280 | }
281 |
282 | // parse number
283 | if (str[pos] === "-" || str[pos] === "+" || str[pos] === "." || (str[pos] >= "0" && str[pos] <= "9")) {
284 | var body = "";
285 | for (var i = pos; i < str.length; i++) {
286 | if (str[i] === "-" || str[i] === "+" || str[i] === "." || (str[i] >= "0" && str[i] <= "9")) {
287 | body += str[i];
288 | }
289 | else {
290 | return {
291 | originLength: body.length,
292 | body: body
293 | };
294 | }
295 | }
296 | throw new Error("Broken JSON number body near " + body);
297 | }
298 |
299 | // parse object
300 | if (str[pos] === "{" || str[pos] === "[") {
301 | var stack = [str[pos]];
302 | var body = str[pos];
303 | for (var i = pos + 1; i < str.length; i++) {
304 | body += str[i];
305 | if (str[i] === "\\") {
306 | if (i + 1 < str.length) body += str[i + 1];
307 | i++;
308 | }
309 | else if (str[i] === "\"") {
310 | if (stack[stack.length - 1] === "\"") {
311 | stack.pop();
312 | }
313 | else if (stack[stack.length - 1] !== "'") {
314 | stack.push(str[i]);
315 | }
316 | }
317 | else if (str[i] === "'") {
318 | if (stack[stack.length - 1] === "'") {
319 | stack.pop();
320 | }
321 | else if (stack[stack.length - 1] !== "\"") {
322 | stack.push(str[i]);
323 | }
324 | }
325 | else if (stack[stack.length - 1] !== "\"" && stack[stack.length - 1] !== "'") {
326 | if (str[i] === "{") {
327 | stack.push("{");
328 | }
329 | else if (str[i] === "}") {
330 | if (stack[stack.length - 1] === "{") {
331 | stack.pop();
332 | }
333 | else {
334 | throw new Error("Broken JSON " + (str[pos] === "{" ? "object" : "array") + " body near " + body);
335 | }
336 | }
337 | else if (str[i] === "[") {
338 | stack.push("[");
339 | }
340 | else if (str[i] === "]") {
341 | if (stack[stack.length - 1] === "[") {
342 | stack.pop();
343 | }
344 | else {
345 | throw new Error("Broken JSON " + (str[pos] === "{" ? "object" : "array") + " body near " + body);
346 | }
347 | }
348 | }
349 | if (!stack.length) {
350 | return {
351 | originLength: i - pos,
352 | body: body
353 | };
354 | }
355 | }
356 | throw new Error("Broken JSON " + (str[pos] === "{" ? "object" : "array") + " body near " + body);
357 | }
358 | throw new Error("Broken JSON body near " + str.substr((pos - 5 >= 0) ? pos - 5 : 0, 50));
359 | }
360 |
361 | canBeKeyHead(ch) {
362 | if (ch[0] === "\\") return false;
363 | if ((ch[0] >= 'a' && ch[0] <= 'z') || (ch[0] >= 'A' && ch[0] <= 'Z') || ch[0] === '_') return true;
364 | if (ch[0] >= '0' && ch[0] <= '9') return true;
365 | if (ch[0] === '$') return true;
366 | if (ch.charCodeAt(0) > 255) return true;
367 | return false;
368 | }
369 |
370 | isBlankChar(ch) {
371 | return ch === " " || ch === "\n" || ch === "\t";
372 | }
373 | }
374 |
--------------------------------------------------------------------------------
/src/util/referrer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * getReferrerUrl returns the last visited URL
3 | */
4 | export function getReferrerUrl() {
5 | const url = oc.useTurbo && oc.useTurbo()
6 | ? oc.AjaxTurbo.controller.getLastVisitUrl()
7 | : getReferrerFromSameOrigin();
8 |
9 |
10 | if (!url || isSameBaseUrl(url)) {
11 | return null;
12 | }
13 |
14 | return url;
15 | }
16 |
17 | function getReferrerFromSameOrigin() {
18 | if (!document.referrer) {
19 | return null;
20 | }
21 |
22 | // Fallback when turbo router is not activated
23 | try {
24 | const referrer = new URL(document.referrer);
25 | if (referrer.origin !== location.origin) {
26 | return null;
27 | }
28 |
29 | const pushReferrer = localStorage.getItem('ocPushStateReferrer');
30 | if (pushReferrer && pushReferrer.indexOf(referrer.pathname) === 0) {
31 | return pushReferrer;
32 | }
33 |
34 | return document.referrer;
35 | }
36 | catch (e) {
37 | }
38 | }
39 |
40 | function isSameBaseUrl(url) {
41 | const givenUrl = new URL(url, window.location.origin),
42 | currentUrl = new URL(window.location.href);
43 |
44 | return givenUrl.origin === currentUrl.origin && givenUrl.pathname === currentUrl.pathname;
45 | }
46 |
--------------------------------------------------------------------------------
/src/util/wait.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Function to wait for predicates.
3 | * @param {function() : Boolean} predicate - A function that returns a bool
4 | * @param {Number} [timeout] - Optional maximum waiting time in ms after rejected
5 | */
6 | export function waitFor(predicate, timeout) {
7 | return new Promise((resolve, reject) => {
8 | const check = () => {
9 | if (!predicate()) {
10 | return;
11 | }
12 | clearInterval(interval);
13 | resolve();
14 | };
15 | const interval = setInterval(check, 100);
16 | check();
17 |
18 | if (!timeout) {
19 | return;
20 | }
21 |
22 | setTimeout(() => {
23 | clearInterval(interval);
24 | reject();
25 | }, timeout);
26 | });
27 | }
28 |
29 | /**
30 | * Function to wait for the DOM to be ready, if not already
31 | */
32 | export function domReady() {
33 | return new Promise((resolve) => {
34 | if (document.readyState === 'loading') {
35 | document.addEventListener('DOMContentLoaded', () => resolve());
36 | }
37 | else {
38 | resolve();
39 | }
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'octobercms';
2 |
3 | type Constructor = new (...args: any[]) => T;
4 |
5 | type ResponseCallback = (data: T, statusCode: number, xhr: XMLHttpRequest) => void;
6 |
7 | type DataResponse = Record;
8 |
9 | export interface ProgressBar {
10 | show: () => void;
11 | hide: () => void;
12 | }
13 |
14 | export interface RequestOptions {
15 | update?: Record;
16 | confirm?: string;
17 | data?: unknown;
18 | query?: unknown;
19 | headers?: Record;
20 | redirect?: string;
21 | beforeUpdate?: ResponseCallback;
22 | afterUpdate?: ResponseCallback;
23 | success?: ResponseCallback;
24 | error?: ResponseCallback;
25 | complete?: () => void;
26 | form?: HTMLElement | string;
27 | flash?: boolean | string;
28 | files?: FormData;
29 | download?: boolean;
30 | bulk?: boolean;
31 | browserValidate?: boolean;
32 | browserTarget?: string;
33 | loading?: string;
34 | progressBar?: boolean;
35 | handleConfirmMessage?: (message: string, promise: Promise) => void;
36 | handleErrorMessage?: (message: string) => void;
37 | handleValidationMessage?: (message: string, fields: Record) => void;
38 | handleFlashMessage?: (message: string, type: string) => void;
39 | handleRedirectResponse?: (url: string) => void;
40 | }
41 |
42 | export interface Deferred {
43 | resolve: (...args) => Deferred;
44 | reject: (...args) => Deferred;
45 | notify: (...args) => Deferred;
46 | abort: () => void;
47 | done: (...args) => Deferred;
48 | fail: (...args) => Deferred;
49 | progress: (...args) => Deferred;
50 | always: (...args) => Deferred;
51 | then: (...args) => Deferred;
52 | promise: (...args) => Deferred;
53 | }
54 |
55 | export interface AjaxEventContext {
56 | el: Document | HTMLElement;
57 | handler: string;
58 | options: RequestOptions;
59 | }
60 |
61 | export interface AjaxBeforeSendEvent extends Event {
62 | detail: {
63 | context: AjaxEventContext;
64 | };
65 | }
66 |
67 | export interface AjaxUpdateEvent extends Event {
68 | detail: {
69 | context: AjaxEventContext;
70 | data: DataResponse;
71 | responseCode: number;
72 | xhr: XMLHttpRequest;
73 | };
74 | }
75 |
76 | export interface AjaxBeforeUpdateEvent extends AjaxUpdateEvent {
77 |
78 | }
79 |
80 | export interface AjaxUpdateCompleteEvent extends AjaxUpdateEvent {
81 |
82 | }
83 |
84 | export interface AjaxRequestResponseEvent extends Event {
85 | detail: {
86 | context: AjaxEventContext;
87 | data: DataResponse;
88 | responseCode: number;
89 | xhr: XMLHttpRequest;
90 | };
91 | }
92 |
93 | export interface AjaxRequestSuccessEvent extends AjaxRequestResponseEvent {
94 |
95 | }
96 |
97 | export interface AjaxRequestErrorEvent extends AjaxRequestResponseEvent {
98 |
99 | }
100 |
101 | export interface AjaxErrorMessageEvent extends Event {
102 | detail: {
103 | message: string;
104 | }
105 | }
106 |
107 | export interface AjaxConfirmMessageEvent extends Event {
108 | detail: {
109 | message: string;
110 | promise: Deferred;
111 | };
112 | }
113 |
114 | export interface AjaxSetupEvent extends Event {
115 | detail: {
116 | context: AjaxEventContext;
117 | };
118 | }
119 |
120 | export interface AjaxPromiseEvent extends Event {
121 | detail: {
122 | context: AjaxEventContext;
123 | }
124 | }
125 |
126 | export interface AjaxFailEvent extends AjaxRequestResponseEvent {
127 |
128 | }
129 |
130 | export interface AjaxDoneEvent extends AjaxRequestResponseEvent {
131 |
132 | }
133 |
134 | export interface AjaxAlwaysEvent extends AjaxRequestResponseEvent {
135 |
136 | }
137 |
138 | export interface AjaxInvalidFieldEvent extends Event {
139 | detail: {
140 | element: HTMLElement;
141 | fieldName: string;
142 | errorMsg: string;
143 | isFirst: boolean;
144 | };
145 | }
146 |
147 | export interface ObserveControlBase {
148 | init: () => void;
149 | connect: () => void;
150 | disconnect: () => void;
151 | }
152 |
153 | declare function ajax(handler: string, options: RequestOptions): void;
154 | declare function request(element: HTMLElement | string, handler: string, options: RequestOptions): void;
155 | declare function parseJson(json: string): void;
156 | declare function flashMsg(options: { text: string, class: string, interval?: number }): void;
157 | declare function useTurbo(): boolean;
158 | declare function visit(location: string, options?: { scroll?: boolean; action: string }): void;
159 | declare function registerControl(id: string, control: Constructor): void;
160 | declare function importControl(id: string): void;
161 | declare function fetchControl(element: HTMLElement | string): ObserveControlBase;
162 | declare function fetchControls(element: HTMLElement | string): ObserveControlBase[];
163 | declare var progressBar: ProgressBar;
164 |
165 | export {
166 | ajax,
167 | request,
168 | parseJson,
169 | flashMsg,
170 | progressBar,
171 | useTurbo,
172 | visit,
173 | registerControl,
174 | importControl,
175 | fetchControl,
176 | fetchControls
177 | };
178 |
179 | declare global {
180 | interface Window {
181 | oc: {
182 | ajax: typeof ajax;
183 | request: typeof request;
184 | parseJson: typeof parseJson;
185 | flashMsg: typeof flashMsg;
186 | progressBar?: typeof progressBar; // Optional, only available with extra's
187 | useTurbo?: typeof useTurbo; // Optional, only available with turbo
188 | visit?: typeof visit; // Optional, only available with turbo
189 | ControlBase?: typeof ObserveControlBase; // Optional, only available with observe
190 | registerControl?: typeof registerControl; // Optional, only available with observe
191 | importControl?: typeof importControl; // Optional, only available with observe
192 | fetchControl?: typeof fetchControl; // Optional, only available with observe
193 | fetchControls?: typeof fetchControls; // Optional, only available with observe
194 | },
195 | }
196 |
197 | interface GlobalEventHandlersEventMap {
198 | 'ajax:before-send': AjaxBeforeSendEvent;
199 | 'ajax:before-update': AjaxBeforeUpdateEvent;
200 | 'ajax:update': AjaxUpdateEvent;
201 | 'ajax:update-complete': AjaxUpdateCompleteEvent;
202 | 'ajax:request-success': AjaxRequestSuccessEvent;
203 | 'ajax:request-error': AjaxRequestErrorEvent;
204 | 'ajax:error-message': AjaxErrorMessageEvent;
205 | 'ajax:confirm-message': AjaxConfirmMessageEvent;
206 | 'ajax:setup': AjaxSetupEvent;
207 | 'ajax:promise': AjaxPromiseEvent;
208 | 'ajax:fail': AjaxFailEvent;
209 | 'ajax:done': AjaxDoneEvent;
210 | 'ajax:always': AjaxAlwaysEvent;
211 | 'ajax:invalid-field': AjaxInvalidFieldEvent;
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 |
3 | module.exports = {
4 | plugins: [],
5 | externals: {}
6 | };
7 |
--------------------------------------------------------------------------------
/webpack.mix.js:
--------------------------------------------------------------------------------
1 | const mix = require('laravel-mix');
2 | const webpackConfig = require('./webpack.config');
3 |
4 | /*
5 | |--------------------------------------------------------------------------
6 | | Mix Asset Management
7 | |--------------------------------------------------------------------------
8 | |
9 | | Mix provides a clean, fluent API for defining some Webpack build steps
10 | | for your theme assets. By default, we are compiling the CSS
11 | | file for the application as well as bundling up all the JS files.
12 | |
13 | */
14 |
15 | mix
16 | .webpackConfig(webpackConfig)
17 | .options({
18 | manifest: false,
19 | });
20 |
21 | mix.js('src/framework-bundle.js', 'dist/framework-bundle.min.js');
22 | mix.js('src/framework-extras.js', 'dist/framework-extras.min.js');
23 | mix.js('src/framework-turbo.js', 'dist/framework-turbo.min.js');
24 | mix.js('src/framework.js', 'dist/framework.min.js');
25 |
26 | if (!mix.inProduction()) {
27 | mix.js('src/framework-bundle.js', 'dist/framework-bundle.js');
28 | mix.js('src/framework-extras.js', 'dist/framework-extras.js');
29 | mix.js('src/framework-turbo.js', 'dist/framework-turbo.js');
30 | mix.js('src/framework.js', 'dist/framework.js');
31 | }
32 |
--------------------------------------------------------------------------------