Skip to content

Instantly share code, notes, and snippets.

@murb
Last active November 14, 2016 01:13
Show Gist options
  • Save murb/c6b555aafde1677f1d7c933a7c1c6b3f to your computer and use it in GitHub Desktop.
Save murb/c6b555aafde1677f1d7c933a7c1c6b3f to your computer and use it in GitHub Desktop.
/** FormStore
The basic function of FormStore is to allow Forms to function, even when the
page serving it is offline. It stores FormData in good'ol localstorage (good
enough for this purpose), and tries to resubmit it when backonline. It knows
when it is online when it get's a valid 200 OK response from /heartbeat.
When going online, it only removes the stored from from the local storage when
the form submit was successful.
FormStore can be initialized by running: FormStore.init(), and it will make add
offline fallbacks to all forms.
Rails users: When resubmitting it will rewrite the Rails authenticity_token when
present.
*/
var FormStore = {
translation: {
UNCACHED: 'Er is geen offline mogelijkheid op dit moment [UNCACHED]',
IDLE: 'De applicatie is klaar voor offline gebruik [IDLE]',
CHECKING: 'Bezig met controleren [CHECKING]',
DOWNLOADING: 'Bezig met downloaden [DOWNLOADING]',
UPDATEREADY: 'De applicatie is nu klaar voor offline gebruik [UPDATEREADY]',
OBSOLETE: 'De applicatie moet bijgewerkt worden voor offline gebruik [OBSOLETE]',
OFFLINE_UNKNOWN: 'Onbekende status van de offline mogelijkheid',
pressAgainToTryAgain: 'druk nogmaals om (toch) te bevestigen',
filesCannotBeStoredOffline: "Bestanden kunnen niet offline worden opgeslagen, de data wordt wel verwerkt.",
validationErrorsOccurred: "Niet alle velden zijn juist ingevuld, controleer de invoer."
},
heartBeatLocation: "/heartbeat",
heartBeatSpacing: 10000,
signInMatcher: /sign_in/,
fireEvent: function(name, data) {
var e = document.createEvent("Event");
e.initEvent(name, true, true);
e.data = data;
window.dispatchEvent(e);
},
appCache: {
status: function() {
appCache = window.applicationCache;
switch (appCache.status) {
case appCache.UNCACHED: // UNCACHED == 0
return FormStore.translation.UNCACHED;
case appCache.IDLE: // IDLE == 1
return FormStore.translation.IDLE;
case appCache.CHECKING: // CHECKING == 2
return FormStore.translation.CHECKING;
case appCache.DOWNLOADING: // DOWNLOADING == 3
return FormStore.translation.DOWNLOADING;
case appCache.UPDATEREADY: // UPDATEREADY == 4
return FormStore.translation.UPDATEREADY;
case appCache.OBSOLETE: // OBSOLETE == 5
return FormStore.translation.OBSOLETE;
default:
return FormStore.translation.OFFLINE_UNKNOWN;
}
},
update: function() {
appCache = window.applicationCache;
if (appCache.status === appCache.UPDATEREADY) {
appCache.update();
}
},
forceUpdate: function() {
appCache = window.applicationCache;
if (appCache.status === appCache.IDLE) {
appCache.update();
appCache.swapCache();
}
}
},
heartBeatLastSentAt: null,
setBodyTagOnline: function() {
var elem = document.getElementsByTagName("body")[0]
elem.classList.remove("offline");
elem.classList.add("online");
},
setBodyTagOffline: function() {
var elem = document.getElementsByTagName("body")[0]
elem.classList.remove("online");
elem.classList.add("offline");
},
checkOnlineState: function() {
if (!FormStore.heartBeatLastSentAt || ((Date.now() - FormStore.heartBeatLastSentAt) > FormStore.heartBeatSpacing) ) {
FormStore.heartBeatLastSentAt = Date.now();
FormStore.send(FormStore.heartBeatLocation, {
timeout: 10000,
onSuccess: function(source){
if (FormStore.online !== true) {
FormStore.fireEvent("connectionBackOnline");
}
FormStore.setBodyTagOnline();
FormStore.online = true;
setTimeout(function() {
FormStore.checkOnlineState();
}, 10000);
},
onFail: function(source){
if (FormStore.online !== false) {
FormStore.fireEvent("connectionWentOffline");
}
FormStore.setBodyTagOffline();
FormStore.online = false;
setTimeout(function() {
FormStore.checkOnlineState();
}, 50000);
},
forceCheck: true
});
}
},
online: null,
Store: {
name: "FormStore",
forEach: function(callback) {
var keys = Object.keys(localStorage);
keys.forEach(function(key){
if (key.search(FormStore.Store.name) == 0 && key.search("lastStoreIndex") == -1) {
callback(key.replace("FormStore",""))
}
})
},
first: function() {
var f = null;
FormStore.Store.forEach(function(a){
if (f == null ) f = a;
});
return f;
},
resetLastStoreIndex: function() {
return localStorage.removeItem(FormStore.Store.name+"lastStoreIndex");
},
lastStoreIndex: function() {
return FormStore.Store.read("lastStoreIndex") ? FormStore.Store.read("lastStoreIndex") : 0;
},
increaseLastStoreIndex: function() {
currentIndex = FormStore.Store.lastStoreIndex();
FormStore.Store.store("lastStoreIndex", currentIndex+1, {skipIncrease: true});
},
flushAll: function() {
FormStore.Store.forEach(function(k){
localStorage.removeItem(FormStore.Store.generateInternalKey(k));
});
},
count_by_key_start: function(keystart) {
var count = 0
FormStore.Store.forEach(function(a) {
if (a.match(new RegExp("^"+keystart))) {
count++
}
});
return count;
},
count: function() {
var count = 0
FormStore.Store.forEach(function() {
count++
});
return count;
},
markPrivate: function(key) {
// console.log("marking "+ key + "as private...")
data = FormStore.Store.read(key);
FormStore.Store.store(key,data,{"prefix":"private"});
private_data = FormStore.Store.read(key,{"prefix":"private"});
equaldata = JSON.stringify(data.data)==JSON.stringify(private_data.data)
if (equaldata) {
FormStore.Store.remove(key);
};
},
markPublic: function(key) {
private_data = FormStore.Store.read(key,{"prefix":"private"}); //read private variant
if (private_data) {
FormStore.Store.store(key,private_data); //store it public
new_public_data = FormStore.Store.read(key);
equaldata = JSON.stringify(new_public_data.data)==JSON.stringify(private_data.data)
if (equaldata) {
FormStore.Store.remove(key,{"prefix":"private"});
}
}
},
generateInternalKey: function(name, options) {
int_key = FormStore.Store.name+name;
if (options && typeof options['prefix'] === 'string') {
int_key = options['prefix'] + int_key;
}
return int_key;
},
remove: function(key, options) {
// console.log ("REMOVING!!! "+ key)
int_key = FormStore.Store.generateInternalKey(key, options);
jsonifiedValueStore = localStorage.getItem(int_key);
return localStorage.removeItem(int_key);
},
store: function(key, value, options){
skipIncrease = (options && options.skipIncrease) ? true : false;
key = FormStore.Store.generateInternalKey(key, options);
if (!skipIncrease) {
FormStore.Store.increaseLastStoreIndex();
}
var valueStore = {
createdAt: Date(),
value: value
};
var jsonifiedValueStore = JSON.stringify(valueStore);
// console.log("Storing on: "+key+", value: "+value);
return localStorage.setItem(key, jsonifiedValueStore);
},
read: function(key,options){
int_key = FormStore.Store.generateInternalKey(key, options);
jsonifiedValueStore = localStorage.getItem(int_key);
if (jsonifiedValueStore) {
valueStore = JSON.parse(jsonifiedValueStore);
return valueStore.value;
} else {
valueStore = JSON.parse(localStorage.getItem(key));
return (valueStore ? valueStore.value : null);
}
}
},
InstanceMethods: {
toFormData: function() {
var tmp = new FormData();
for (elem_key in this.data) {
elem_data = this.data[elem_key]
if (elem_key == "authenticity_token") {
csrf_token = document.getElementsByName("csrf-token")[0].content
elem_data = csrf_token ? csrf_token : elem_data
}
if (typeof elem_data === 'object' && elem_data.forEach && elem_data.length > 0) {
elem_data.forEach(function(value){
tmp.append(elem_key,value);
});
} else {
tmp.append(elem_key,elem_data);
}
}
return tmp;
},
submitForm: function(options) {
options = options ? options : {}
options.background = options.background ? true : false;
options.reload = options.no_reload ? false : true;
// console.log("SubmittingForm (reload:"+ options.reload)
var formAction = this.action;
var formMethod = this.method;
FormStore.send(formAction, {
method: formMethod,
timeout: 10000,
source: this,
data: this.toFormData(),
redirectOnSuccess: options.reload,
background: options.background,
forceCheck: options.forceCheck,
onSuccess: function(source) {
if (options["storeKey"]) {
// console.log("Submit succesful, removing key from store.");
FormStore.Store.remove(options["storeKey"]);
FormStore.Store.remove(options["storeKey"],{"prefix":"private"});
} else {
// console.log("Submit succesful, never stored in keystore");
}
if (options['onSuccess']) options['onSuccess']();
},
onFail: function(source){
if (!options["storeKey"]) {
// Submit unsuccesful, formdata wasn't in store, adding to store.
source.storeForm();
if (options["reload"]) document.location = formAction;
}
if (options['onFail']) options['onFail']();
}
});
},
storeForm: function() {
var rest_method = this.data["_method"] ? this.data["_method"] : "new"
var key = ""+this.url+"#"+rest_method+"@"+FormStore.Store.lastStoreIndex();
// console.log("Storing form under "+key);
return FormStore.Store.store(key,this);
},
},
Form: {
parseForm: function(form) {
var oData = new FormData(form);
var oDataParsed = {};
var has_files = false
for (elem_key in form.elements) {
elem_i = parseInt(elem_key);
if ((typeof elem_i === 'number') && elem_i != NaN ) {
var element = form.elements[elem_key];
if (element.type === "checkbox" || element.type === "radio") {
if (element.checked) {
oDataParsed[element.name] = element.value;
}
} else if (element.multiple) {
selected = Array.prototype.filter.apply(
element.options, [
function(o) {
return o.selected;
}
]
);
oDataParsed[element.name] = selected.map(function(a){return a.value});;
} else if (element.type === 'file') {
if (element.value) {
has_files = true
// TODO: store it as Base64? Not sure whether this is feasible due to size limits of local store (5MB)
// var file_data;
// reader.onload = (function(aImg) { return function(e) { aImg.src = e.target.result; }; })(img);
}
} else {
oDataParsed[element.name] = element.value;
}
}
}
return (FormStore.DataContainer({
url: form.baseURI,
action: form.getAttribute("action"),
method: form.getAttribute("method"),
data: oDataParsed,
has_files: has_files
}));
},
parseStoredForm: function(untaggedObj) {
return (FormStore.DataContainer(untaggedObj));
}
},
DataContainer: function(options){
this.method = options['method'];
this.action = options['action'];
this.url = options['url'];
this.data = options['data'];
this.has_files = options['has_files'];
this.submitForm = FormStore.InstanceMethods.submitForm;
this.toFormData = FormStore.InstanceMethods.toFormData;
this.storeForm = FormStore.InstanceMethods.storeForm;
return this;
},
send: function(loc, options) {
options = options ? options : {}
var source = options.source;
var timeout = options.timeout ? options.timeout : 30000;
var method = options.method ? options.method : "GET";
var onFail = options.onFail;
var onSuccess = options.onSuccess;
var data = options.data;
var redirectOnSuccess = options.redirectOnSuccess;
var background = options.background;
var forceCheck = options.forceCheck ? true : false
if (FormStore.online || forceCheck) {
var oReq = new XMLHttpRequest();
var noResponseTimer = setTimeout(function() {
oReq.abort();
FormStore.fireEvent("connectionTimeout", {request: oReq, source: source});
if (onFail) onFail(source);
return;
}, timeout);
oReq.open(method, loc, true);
oReq.onload = function(oEvent) {
// console.log(oReq)
if (oReq.status == 200 && !FormStore.signInMatcher.exec(oReq.responseURL)) {
clearTimeout(noResponseTimer);
FormStore.fireEvent("connectionSuccess", {request: oReq, source: source});
if (onSuccess) onSuccess(source);
if (redirectOnSuccess && !background) document.location = oReq.responseURL;
} else {
FormStore.fireEvent("connectionError", {request: oReq, source: source});
if (onFail) onFail(source);
}
};
oReq.send(data);
} else {
// Not attempting to send any data due to offline status of the app
// (use forceCheck=true if you want to check), storing it to temp store");
if (onFail) onFail(source);
}
},
retryStoredForms: function() {
key = FormStore.Store.first();
if (key){
storedform = FormStore.Store.read(key);
FormStore.Store.markPrivate(key);
storedform = FormStore.Form.parseStoredForm(storedform);
storedform.submitForm({background:true, storeKey: key, forceCheck: true,
onSuccess:function() {
FormStore.retryStoredForms()
},
onFail: function() {
console.log("markingpublic on fail")
FormStore.Store.markPublic(key);
}
});
}
},
addListeners: function() {
document.addEventListener("ready", function(e) {
FormStore.setBodyTagOnline();
});
// window.addEventListener("connectionError", function(e) {
// });
// window.addEventListener("connectionSuccess", function(e) {
// });
// window.addEventListener("connectionTimeout", function(e) {
// });
window.addEventListener("connectionWentOffline", function(e) {
console.log("triggered: connectionWentOffline");
FormStore.setBodyTagOffline();
});
window.addEventListener("connectionBackOnline", function(e) {
FormStore.setBodyTagOnline();
FormStore.retryStoredForms();
setTimeout( function(){
FormStore.appCache.update();
}, 2000);
});
},
init: function() {
FormStore.checkOnlineState();
for (var i=0; i<document.forms.length; i++){
var form = document.forms[i];
for (var j=0; j<form.elements.length; j++){
var element = form.elements[j];
element.addEventListener('blur', function(e) {
e.target.classList.add("blurred");
})
}
var backgroundFormSubmit = function(e) {
var target = e.target
var continue_submit = true;
if (target.dataset.skipConfirm != "true") {
continue_submit = confirm(target.dataset.confirm);
}
e.preventDefault();
if (continue_submit || target.dataset.skipConfirm == "true") {
f = FormStore.Form.parseForm(target.form);
f.submitForm({no_reload:true});
document.location = (""+document.location).split("#")[0] + "#new_work"
} else {
old_value = target.value;
target.dataset.skipConfirm = true;
target.value = old_value + " ("+FormStore.translation.pressAgainToTryAgain+")"
setTimeout(function(){
target.value = old_value;
target.dataset.skipConfirm = false;
}, 3000)
}
return false;
}
$(form).find('[type=submit].no-reload').on("click keydown",backgroundFormSubmit);
if (typeof form.dataset.offline !== 'undefined') {
form.addEventListener("submit", function(e){
e.preventDefault();
if (e.target.checkValidity()) {
f = FormStore.Form.parseForm(e.target);
if (f.has_files && FormStore.online) {
e.target.submit();
} else if (f.has_files && !FormStore.online) {
alert(FormStore.translation.filesCannotBeStoredOffline)
f.submitForm();
} else {
f.submitForm();
}
} else {
alert(FormStore.translation.validationErrorsOccurred)
}
return false;
});
}
}
FormStore.addListeners();
}
};
@murb
Copy link
Author

murb commented Apr 12, 2016

Distilled from a project I've been working on... might ship it one day with some extra docs, now it just for illustration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment