query-state
Version:
Application state in query string
539 lines (431 loc) • 14.7 kB
JavaScript
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.queryState = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
/**
* Allows application to access and update current app state via query string
*/
module.exports = queryState;
var eventify = require('ngraph.events');
var windowHistory = require('./lib/windowHistory.js');
/**
* Just a convenience function that returns singleton instance of a query state
*/
queryState.instance = instance;
// this variable holds singleton instance of the query state
var singletonQS;
/**
* Creates new instance of the query state.
*/
function queryState(defaults, options) {
options = options || {};
var history = options.history || windowHistory(defaults, options);
validateHistoryAPI(history);
history.onChanged(updateQuery)
var query = history.get() || Object.create(null);
var api = {
/**
* Gets current state.
*
* @param {string?} keyName if present then value for this key is returned.
* Otherwise the entire app state is returned.
*/
get: getValue,
/**
* Merges current app state with new key/value.
*
* @param {string} key name
* @param {string|number|date} value
*/
set: setValue,
/**
* Similar to `set()`, but only sets value if it was not set before.
*
* @param {string} key name
* @param {string|number|date} value
*/
setIfEmpty: setIfEmpty,
/**
* Releases all resources acquired by query state. After calling this method
* no hash monitoring will happen and no more events will be fired.
*/
dispose: dispose,
onChange: onChange,
offChange: offChange,
getHistoryObject: getHistoryObject,
}
var eventBus = eventify({});
return api;
function onChange(callback, ctx) {
eventBus.on('change', callback, ctx);
}
function offChange(callback, ctx) {
eventBus.off('change', callback, ctx)
}
function getHistoryObject() {
return history;
}
function dispose() {
// dispose all history listeners
history.dispose();
// And remove our own listeners
eventBus.off();
}
function getValue(keyName) {
if (keyName === undefined) return query;
return query[keyName];
}
function setValue(keyName, value) {
var keyNameType = typeof keyName;
if (keyNameType === 'object') {
Object.keys(keyName).forEach(function(key) {
query[key] = keyName[key];
});
} else if (keyNameType === 'string') {
query[keyName] = value;
}
history.set(query);
return api;
}
function updateQuery(newAppState) {
query = newAppState;
eventBus.fire('change', query);
}
function setIfEmpty(keyName, value) {
if (typeof keyName === 'object') {
Object.keys(keyName).forEach(function(key) {
// TODO: Can I remove code duplication? The main reason why I don't
// want recursion here is to avoid spamming `history.set()`
if (key in query) return; // key name is not empty
query[key] = keyName[key];
});
}
if (keyName in query) return; // key name is not empty
query[keyName] = value;
history.set(query);
return api;
}
}
/**
* Returns singleton instance of the query state.
*
* @param {Object} defaults - if present, then it is passed to the current instance
* of the query state. Defaults are applied only if they were not present before.
*/
function instance(defaults) {
if (!singletonQS) {
singletonQS = queryState(defaults);
} else if (defaults) {
singletonQS.setIfEmpty(defaults);
}
return singletonQS;
}
function validateHistoryAPI(history) {
if (!history) throw new Error('history is required');
if (typeof history.dispose !== 'function') throw new Error('dispose is required');
if (typeof history.onChanged !== 'function') throw new Error('onChanged is required');
}
},{"./lib/windowHistory.js":4,"ngraph.events":5}],2:[function(require,module,exports){
/**
* Provides a `null` object that matches history API
*/
module.exports = inMemoryHistory;
function inMemoryHistory(defaults) {
var listeners = [];
var lastQueryObject = defaults;
return {
dispose: dispose,
onChanged: onChanged,
set: set,
get: get
};
function get() {
return lastQueryObject;
}
function set(newQueryObject) {
lastQueryObject = newQueryObject;
setTimeout(function() {
triggerChange(newQueryObject);
}, 0);
}
function dispose() {
listeners = [];
}
function onChanged(changeCallback) {
if (typeof changeCallback !== 'function') {
throw new Error('changeCallback should be a function')
}
listeners.push(changeCallback);
}
function triggerChange(appState) {
listeners.forEach(function(listener) {
listener(appState);
});
}
}
},{}],3:[function(require,module,exports){
/**
* This module is similar to JSON, but it encodes/decodes in query string
* format `key1=value1...`
*/
module.exports = {
parse: parse,
stringify: stringify
};
function stringify(object) {
if (!object) return '';
return Object.keys(object).map(toPairs).join('&');
function toPairs(key) {
var value = object[key];
var pair = encodePart(key);
if (value !== undefined) {
pair += '=' + encodeValue(value);
}
return pair;
}
}
function parse(queryString) {
var query = Object.create(null);
if (!queryString) return query;
queryString.split('&').forEach(decodeRecord);
return query;
function decodeRecord(queryRecord) {
if (!queryRecord) return;
var pair = queryRecord.split('=');
query[decodeURIComponent(pair[0])] = decodeValue(pair[1]);
}
}
function encodeValue(value) {
// TODO: Do I need this?
// if (typeof value === 'string') {
// if (value.match(/^(true|false)$/)) {
// // special handling of strings that look like booleans
// value = JSON.stringify('' + value);
// } else if (value.match(/^-?\d+\.?\d*$/)) {
// // special handling of strings that look like numbers
// value = JSON.stringify('' + value);
// }
// }
if (value instanceof Date) {
value = value.toISOString();
}
var uriValue = encodePart(value);
return uriValue;
}
function encodePart(part) {
// We want to make sure that we also encode symbols like ( and ) correctly
var encoded = encodeURIComponent(part);
return encoded.replace(/[()]/g, saferEscape);
}
function saferEscape(character) {
if (character === ')') return '%29';
if (character === '(') return '%28';
return character; // What?
}
/**
* This method returns typed value from string
*/
function decodeValue(value) {
value = decodeURIComponent(value);
if (value === "") return value;
if (!isNaN(value)) return parseFloat(value);
if (isBolean(value)) return value === 'true';
if (isISODateString(value)) return new Date(value);
return value;
}
function isBolean(strValue) {
return strValue === 'true' || strValue === 'false';
}
function isISODateString(str) {
return str && str.match(/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/)
}
},{}],4:[function(require,module,exports){
/**
* Uses `window` to monitor hash and update hash
*/
module.exports = windowHistory;
var inMemoryHistory = require('./inMemoryHistory.js');
var query = require('./query.js');
function windowHistory(defaults, options) {
// If we don't support window, we are probably running in node. Just return
// in memory history
if (typeof window === 'undefined') return inMemoryHistory(defaults);
// Store all `onChanged()` listeners here, so that we can have just one
// `hashchange` listener, and notify one listeners within single event.
var listeners = [];
var useSearchPart = options && options.useSearch; // prefer query ? over hash #
// This prefix is used for all query strings. So our state is stored as
// my-app.com/#?key=value
var hashPrefix = useSearchPart ? '?' : '#?';
if (options.rewriteHashToSearch) {
rewriteHashToSearch();
}
init();
// This is our public API:
return {
/**
* Adds callback that is called when hash change happen. Callback receives
* current hash string with `#?` sign
*
* @param {Function} changeCallback - a function that is called when hash is
* changed. Callback gets one argument that represents the new state.
*/
onChanged: onChanged,
/**
* Releases all resources
*/
dispose: dispose,
/**
* Sets a new app state
*
* @param {object} appState - the new application state, that should be
* persisted in the hash string
*/
set: set,
/**
* Gets current app state
*/
get: getStateFromHash,
/**
* Allows to rewrite current hash url into search url
*/
rewriteHashToSearch: rewriteHashToSearch
};
// Public API is over. You can ignore this part.
function init() {
var stateFromHash = getStateFromHash();
var stateChanged = false;
if (typeof defaults === 'object' && defaults) {
Object.keys(defaults).forEach(function(key) {
if (key in stateFromHash) return;
stateFromHash[key] = defaults[key]
stateChanged = true;
});
}
if (stateChanged) set(stateFromHash);
}
function rewriteHashToSearch() {
var mergedState = Object.create(null);
var searchString = window.location.search;
if (searchString) mergedState = Object.assign(mergedState, query.parse(searchString.substr(1)));
var hashString = window.location.hash;
if (hashString) mergedState = Object.assign(mergedState, query.parse(hashString.substr(2)));
set(mergedState);
}
function set(appState) {
var hash = hashPrefix + query.stringify(appState);
if (window.history) {
window.history.replaceState(undefined, undefined, hash);
} else {
window.location.replace(hash);
}
}
function onChanged(changeCallback) {
if (typeof changeCallback !== 'function') throw new Error('changeCallback needs to be a function');
// we start listen just once, only if we didn't listen before:
if (listeners.length === 0) {
window.addEventListener('hashchange', onHashChanged, false);
}
listeners.push(changeCallback);
}
function dispose() {
if (listeners.length === 0) return; // no need to do anything.
// Let garbage collector collect all listeners;
listeners = [];
// And release hash change event:
window.removeEventListener('hashchange', onHashChanged, false);
}
function onHashChanged() {
var appState = getStateFromHash();
notifyListeners(appState);
}
function notifyListeners(appState) {
for (var i = 0; i < listeners.length; ++i) {
var listener = listeners[i];
listener(appState);
}
}
function getStateFromHash() {
var baseString = useSearchPart ? window.location.search : window.location.hash;
// or symbol || is used to get empty string when no base string is present.
var queryString = (baseString || hashPrefix).substr(hashPrefix.length);
return query.parse(queryString);
}
}
},{"./inMemoryHistory.js":2,"./query.js":3}],5:[function(require,module,exports){
module.exports = function(subject) {
validateSubject(subject);
var eventsStorage = createEventsStorage(subject);
subject.on = eventsStorage.on;
subject.off = eventsStorage.off;
subject.fire = eventsStorage.fire;
return subject;
};
function createEventsStorage(subject) {
// Store all event listeners to this hash. Key is event name, value is array
// of callback records.
//
// A callback record consists of callback function and its optional context:
// { 'eventName' => [{callback: function, ctx: object}] }
var registeredEvents = Object.create(null);
return {
on: function (eventName, callback, ctx) {
if (typeof callback !== 'function') {
throw new Error('callback is expected to be a function');
}
var handlers = registeredEvents[eventName];
if (!handlers) {
handlers = registeredEvents[eventName] = [];
}
handlers.push({callback: callback, ctx: ctx});
return subject;
},
off: function (eventName, callback) {
var wantToRemoveAll = (typeof eventName === 'undefined');
if (wantToRemoveAll) {
// Killing old events storage should be enough in this case:
registeredEvents = Object.create(null);
return subject;
}
if (registeredEvents[eventName]) {
var deleteAllCallbacksForEvent = (typeof callback !== 'function');
if (deleteAllCallbacksForEvent) {
delete registeredEvents[eventName];
} else {
var callbacks = registeredEvents[eventName];
for (var i = 0; i < callbacks.length; ++i) {
if (callbacks[i].callback === callback) {
callbacks.splice(i, 1);
}
}
}
}
return subject;
},
fire: function (eventName) {
var callbacks = registeredEvents[eventName];
if (!callbacks) {
return subject;
}
var fireArguments;
if (arguments.length > 1) {
fireArguments = Array.prototype.splice.call(arguments, 1);
}
for(var i = 0; i < callbacks.length; ++i) {
var callbackInfo = callbacks[i];
callbackInfo.callback.apply(callbackInfo.ctx, fireArguments);
}
return subject;
}
};
}
function validateSubject(subject) {
if (!subject) {
throw new Error('Eventify cannot use falsy object as events subject');
}
var reservedWords = ['on', 'fire', 'off'];
for (var i = 0; i < reservedWords.length; ++i) {
if (subject.hasOwnProperty(reservedWords[i])) {
throw new Error("Subject cannot be eventified, since it already has property '" + reservedWords[i] + "'");
}
}
}
},{}]},{},[1])(1)
});