Created
November 21, 2011 11:48
-
-
Save virtix/1382411 to your computer and use it in GitHub Desktop.
Trying to do a many-to-may model in django
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @copyright | |
* This program is free software: you can redistribute it and/or modify | |
* it under the terms of the GNU General Public License as published by | |
* the Free Software Foundation, either version 3 of the License, or | |
* (at your option) any later version. | |
* | |
* This program is distributed in the hope that it will be useful, | |
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
* GNU General Public License for more details. | |
* | |
* You should have received a copy of the GNU General Public License | |
* along with this program. If not, see <http://www.gnu.org/licenses/>. | |
* | |
* @author Stephane Roucheray | |
* @see Plugin Page : http://code.google.com/p/jquery-dynamic-form/ | |
* @see Author's Blog : http://sroucheray.org | |
* @see Follow author : http://twitter.com/sroucheray | |
* @extends jQuery (requires at version >= 1.4) | |
* @version 1.0.3 | |
---------------------------------------------------------------------------------------------------------- | |
Changed the way form fields are named. Seems strange the way it was done. | |
TODO: Look into jquery.format( .... ) and create form segment templates. | |
*/ | |
(function($){ | |
/** | |
* @param {String} plusSelector HTML element serving the duplication when clicking on it | |
* @param {String} minusSelector HTML element deleting the cloned form element | |
* @param {Object} options Optional object, can contain any of the parameters below : | |
* limit {Number} : maximum number of duplicate fields authorized | |
* formPrefix {String} : the prefix used to identify a form (if not defined will use normalized source selector) | |
* afterClone {Function} : a callback function called as soon as a duplication is done, | |
* this is useful for custom insertion (you can insert the duplicate anywhere in the DOM), | |
* inserting specific validation on cloned field | |
* - this function will be passed the cloned element as a unique parameter | |
* - return false if the cloned element should not be inserted | |
* normalizeFullForm {Boolean} : normalize all fields in the form (even outside the template) for better server side script handling (default true) | |
* | |
* createColor {String} : color effect when duplicating (requires jQuery UI Effects module) | |
* removeColor {String} : color effect when removing duplicate (idem) | |
* duration {Number} : color effect duration (idem) | |
* | |
* data {Object} : A JSON based representation of the data which will prefill the form (equivalent of the inject method) | |
* | |
*/ | |
$.fn.dynamicForm = function (plusSelector, minusSelector, options){ | |
var source = $(this), | |
minus, | |
plus, | |
template, | |
formFields = "input, checkbox, select, textarea", | |
clones = [], | |
defaults = { | |
duration:1000, | |
normalizeFullForm:true, | |
isSubDynamicForm:false | |
}, | |
subDynamicForm = [], | |
formPrefix; | |
// Set plus and minus elements within sub dynamic form clones | |
if(options.internalSubDynamicForm){ | |
minus = $(options.internalContainer).find(minusSelector); | |
plus = $(options.internalContainer).find(plusSelector); | |
}else{ //Set normal plus an minus element | |
minus = $(minusSelector); | |
plus = $(plusSelector); | |
} | |
// Extend default options with those provided | |
options = $.extend(defaults, options); | |
//Set the form prefix | |
formPrefix = options.formPrefix || source.selector.replace(/\W/g, ""); | |
/** | |
* Clone the form template | |
*/ | |
function cloneTemplate(disableEffect){ | |
var clone, callBackReturn; | |
clone = template.cloneWithAttribut(true); | |
if (typeof options.afterClone === "function") { | |
callBackReturn = options.afterClone(clone); | |
} | |
if(callBackReturn || typeof callBackReturn == "undefined"){ | |
clone.insertAfter(clones[clones.length - 1] || source); | |
} | |
clone.getSource = function(){ | |
return source; | |
}; | |
/* Normalize template id attribute */ | |
if (clone.attr("id")) { | |
clone.attr("id", clone.attr("id") + clones.length); | |
} | |
if (clone.effect && options.createColor && !disableEffect) { | |
clone.effect("highlight", {color:options.createColor}, options.duration); | |
} | |
return clone; | |
} | |
/** | |
* On cloning make the form under the clone dynamic | |
* @param {Object} clone | |
*/ | |
function dynamiseSubClones(clone){ | |
$(subDynamicForm).each(function(){ | |
var plus = this.getPlusSelector(), minus = this.getMinusSelector(), options = this.getOptions(), selector = this.selector; | |
clone.find(this.selector).each(function(){ | |
options = $.extend( | |
{ | |
internalSubDynamicForm:true, | |
internalContainer:clone, | |
isInAClone:true, | |
outerCloneIndex:clones.length, | |
selector:selector | |
}, options); | |
$(this).dynamicForm(plus, minus, options); | |
}); | |
}); | |
} | |
/** | |
* Handle click on plus when plus element is inside the template | |
* @param {Object} event | |
*/ | |
function innerClickOnPlus(event, extraParams){ | |
var clone, | |
currentClone = clones[clones.length -1] || source; | |
event.preventDefault(); | |
currentClone.find(minusSelector).show(); | |
currentClone.find(plusSelector).hide(); | |
if (clones.length === 0) { | |
source.find(minusSelector).hide(); | |
} | |
clone = cloneTemplate(extraParams); | |
plus = clone.find(plusSelector); | |
minus = clone.find(minusSelector); | |
minus.get(0).removableClone = clone; | |
minus.click(innerClickOnMinus); | |
if (options.limit && (options.limit - 2) > clones.length) { | |
plus.show(); | |
minus.show(); | |
}else{ | |
plus.hide(); | |
minus.show(); | |
} | |
clones.push(clone); | |
normalizeClone(clone, clones.length); | |
dynamiseSubClones(clone); | |
} | |
/** | |
* Handle click on plus when plus element is outside the template | |
* @param {Object} event | |
*/ | |
function outerClickOnPlus(event, extraParams){ | |
var clone; | |
event.preventDefault(); | |
/* On first add, normalize source */ | |
if (clones.length === 0) { | |
minus.show(); | |
} | |
clone = cloneTemplate(extraParams); | |
if (options.limit && (options.limit - 3) < clones.length) { | |
plus.hide(); | |
} | |
clones.push(clone); | |
normalizeClone(clone, clones.length); | |
dynamiseSubClones(clone); | |
} | |
/** | |
* Handle click on minus when minus element is inside the template | |
* @param {Object} event | |
*/ | |
function innerClickOnMinus(event){ | |
event.preventDefault(); | |
if (this.removableClone.effect && options.removeColor) { | |
that = this; | |
this.removableClone.effect("highlight", { | |
color: options.removeColor | |
}, options.duration, function(){that.removableClone.remove();}); | |
} else { | |
this.removableClone.remove(); | |
} | |
clones.splice($.inArray(this.removableClone, clones),1); | |
if (clones.length === 0){ | |
source.find(plusSelector).show(); | |
}else{ | |
clones[clones.length -1].find(plusSelector).show(); | |
} | |
} | |
/** | |
* Handle click on minus when minus element is outside the template | |
* @param {Object} event | |
*/ | |
function outerClickOnMinus(event){ | |
event.preventDefault(); | |
var clone = clones.pop(); | |
if (clones.length >= 0) { | |
if (clone.effect && options.removeColor) { | |
that = this; | |
clone.effect("highlight", { | |
color: options.removeColor, mode:"hide" | |
}, options.duration, function(){clone.remove();}); | |
} else { | |
clone.remove(); | |
} | |
} | |
if (clones.length === 0) { | |
minus.hide(); | |
} | |
plus.show(); | |
} | |
/** | |
* Normalize ids and name attributes of all children forms fields of an element | |
* @param {Object} elmnt | |
*/ | |
function normalizeSource(elmnt, prefix, index){ | |
elmnt.find(formFields).each(function(){ | |
var that = $(this), | |
nameAttr = that.attr("name"), | |
origNameAttr = that.attr("origname"), | |
idAttr = that.attr("id"), | |
origId = that.attr("origid"); | |
/* Normalize field name attributes */ | |
if (nameAttr) { | |
//TODO: | |
that.attr("name", nameAttr + '_' + index); | |
console.log(that) | |
} | |
//if (!/\[\]$/.exec(nameAttr)) that.attr("name", nameAttr + index); | |
else if(origNameAttr){ | |
//This is a subform (thus prefix is not the same as below) | |
that.attr("name", prefix+"["+index+"]"+"["+origNameAttr+"]"); | |
}else{ | |
//This is the main form | |
that.attr("origname", nameAttr); | |
//This is the main normalization | |
that.attr("name", prefix+"["+index+"]"+"["+nameAttr+"]"); | |
} | |
/* Normalize field id attributes */ | |
if (idAttr) { | |
/* Normalize attached label */ | |
that.attr("origid", idAttr); | |
$("label[for='"+idAttr+"']").each(function(){ | |
$(this).attr("origfor", idAttr); | |
$(this).attr("for", idAttr + index); | |
}); | |
that.attr("id", idAttr + index); | |
} | |
}); | |
} | |
function normalizeClone(elmnt, index){ | |
var match, matchRegEx = /(.+\[[^\[\]]+\]\[)(\d+)(\]\[[^\[\]]+\])$/; | |
elmnt.find(formFields).each(function(){ | |
var that = $(this), | |
nameAttr = that.attr("name"), | |
origNameAttr = that.attr("origname"), | |
idAttr = that.attr("id"), | |
counter = index; | |
match = matchRegEx.exec(nameAttr); | |
//that.attr("name", match[1]+index+match[3]); | |
if (nameAttr) { | |
//TODO: | |
that.attr("name", nameAttr.replace(/[0-9]/, counter++) ); | |
console.log (that) | |
} | |
else if (idAttr) { | |
newIdAttr = idAttr.slice(0,-1) + index; | |
that.attr("origid", idAttr); | |
elmnt.find("label[for='"+idAttr+"']").each(function(){ | |
$(this).attr("for", newIdAttr); | |
}); | |
that.attr("id", newIdAttr); | |
} | |
}); | |
} | |
function normalizeSubClone(elmnt, formPrefix, index){ | |
var match, matchRegEx = /(.+)\[([^\[\]]+)\]$/; | |
elmnt.find(formFields).each(function(){ | |
var that = $(this), | |
nameAttr = that.attr("name"), | |
idAttr = that.attr("id"), | |
newIdAttr = idAttr + index, | |
match = matchRegEx.exec(nameAttr); | |
that.attr("name", match[1]+"["+formPrefix+"]"+"["+index+"]"+"["+match[2]+"]"); | |
if (idAttr) { | |
that.attr("origid", idAttr); | |
elmnt.find("label[for='"+idAttr+"']").each(function(){ | |
$(this).attr("for", newIdAttr); | |
}); | |
that.attr("id", newIdAttr); | |
} | |
}); | |
} | |
//Add a function to enable sub dynamic forms to register themselves | |
source.each(function(){ | |
$.extend(this, { | |
addSubDynamicForm : function(dynamicForm){ | |
subDynamicForm.push(dynamicForm); | |
}, | |
getFormPrefix : function(){ | |
return formPrefix; | |
}, | |
getSource : function(){ | |
return source; | |
} | |
}); | |
}); | |
//Check if this dynamic form is a sub dynamic form | |
var isMainForm = true; | |
$(this).parentsUntil("body").each(function(){ | |
if($.isFunction(this.addSubDynamicForm) && !options.isSubDynamicForm){ | |
isMainForm = false; | |
options.isSubDynamicForm = true; | |
var suboptions = $.extend( | |
{ | |
internalSubDynamicForm:true, | |
internalContainer:this | |
}, options); | |
this.addSubDynamicForm(source); | |
formPrefix = this.getFormPrefix()+"[0]["+formPrefix+"]"; | |
return false; | |
} | |
}); | |
if(isMainForm && !options.isInAClone){ | |
//Main form name and prefix for the main form are the same for now | |
formPrefix = formPrefix+"["+formPrefix+"]"; | |
} | |
if(!options.isInAClone){ | |
normalizeSource(source, formPrefix, 0); | |
}else{ | |
formPrefix = formPrefix || options.selector.replace(/\W/g, ""); | |
//Main form name and prefix for the main form are the same for now | |
normalizeSubClone(source, formPrefix, 0); | |
} | |
if(isMainForm && options.normalizeFullForm && !options.isInAClone){ | |
//Normalize all forms outside duplicated template in order to ease server-side parsing | |
$(this).parentsUntil("form").each(function(){ | |
var theForm = $(this).parent().get(0); | |
$(theForm).find(formFields).filter("[type!=submit]").each(function(){ | |
var that = $(this), | |
nameAttr = that.attr("name"), | |
origNameAttr = that.attr("origname"), | |
idAttr = that.attr("id"), | |
origId = that.attr("origid"); | |
if(!origNameAttr){ | |
// Normalize field name attributes | |
if (!nameAttr) { | |
//TODO: that.attr("name", formPrefix+"form"+index + "["+index+"]"); | |
that.attr("name", prefix+"["+index+"]"+"["+origNameAttr+"]"); | |
} | |
//It's the main form | |
that.attr("origname", nameAttr); | |
//This is the main normalization | |
that.attr("name", formPrefix+"["+nameAttr+"]"); | |
} | |
}); | |
}); | |
} | |
isPlusDescendentOfTemplate = source.find("*").filter(function(){ | |
return this == plus.get(0); | |
}); | |
isPlusDescendentOfTemplate = isPlusDescendentOfTemplate.length > 0 ? true : false; | |
/* Hide minus element */ | |
minus.hide(); | |
/* If plus element is within the template */ | |
if (isPlusDescendentOfTemplate) { | |
/* Handle click on plus */ | |
plus.click(innerClickOnPlus); | |
}else{ | |
/* If plus element is out of the template */ | |
/* Handle click on plus */ | |
plus.click(outerClickOnPlus); | |
/* Handle click on minus */ | |
minus.click(outerClickOnMinus); | |
} | |
$.extend( source, { | |
getPlus : function(){ | |
return plus; | |
}, | |
getPlusSelector : function(){ | |
return plusSelector; | |
}, | |
getMinus : function(){ | |
return minus; | |
}, | |
getMinusSelector : function(){ | |
return minusSelector; | |
}, | |
getOptions : function(){ | |
return options; | |
}, | |
getClones : function(){ | |
var clonesAndSource = [source]; | |
return clonesAndSource.concat(clones); | |
}, | |
getSource : function(){ | |
return source; | |
}, | |
inject : function(data){ | |
/** | |
* Fill data of each main dynamic form clones | |
* @param {Object} formIndex | |
* @param {Object} formValue | |
*/ | |
function fillData(formIndex, formValue){ | |
//Loop over data form array (each item will match a specific clone) | |
var mainForm = this; | |
//Shows required additional dynamic forms | |
if(formIndex > 0){ | |
mainForm.getSource().getPlus().trigger("click", ["disableEffect"]); | |
} | |
var clone = mainForm.get(0).getSource().getClones()[formIndex]; | |
$.each(formValue, function(index, value){ | |
if($.isArray(value)){ | |
mainForm = clone.find("#"+index); | |
if(typeof mainForm.get(0).getSource === "function"){ | |
$.each(value, $.proxy( fillData, mainForm.get(0).getSource())); | |
} | |
}else{ | |
var formElements = mainForm.getSource().getClones()[formIndex].find("[origname='"+index+"']"); | |
if(formElements){ | |
if(formElements.get(0).tagName.toLowerCase() == "input"){ | |
/* Fill in radio input */ | |
if(formElements.attr("type") == "radio"){ | |
formElements.filter("[value='"+value+"']").attr("checked", "checked"); | |
}else if(formElements.attr("type") == "checkbox"){/* Fill in checkbox input */ | |
formElements.attr("checked", "checked"); | |
}else{ | |
formElements.attr("value", value); | |
} | |
}else if(formElements.get(0).tagName.toLowerCase() == "textarea"){ | |
/* Fill in textarea */ | |
formElements.text(value); | |
}else if(formElements.get(0).tagName.toLowerCase() == "select"){ | |
/* Fill in select */ | |
$(formElements.get(0)).find("option").each(function(){ | |
if($(this).text() == value || $(this).attr("value") == value){ | |
$(this).attr("selected", "selected"); | |
} | |
}); | |
} | |
} | |
} | |
}); | |
} | |
//Loop over each form | |
$.each(data, $.proxy( fillData, source )); | |
} | |
}); | |
template = source.cloneWithAttribut(true); | |
if(options.data){ | |
source.inject(options.data); | |
} | |
return source; | |
}; | |
/** | |
* jQuery original clone method decorated in order to fix an IE < 8 issue | |
* where attributs especially name are not copied | |
*/ | |
jQuery.fn.cloneWithAttribut = function( withDataAndEvents ){ | |
if ( jQuery.support.noCloneEvent ){ | |
return $(this).clone(withDataAndEvents); | |
}else{ | |
$(this).find("*").each(function(){ | |
$(this).data("name", $(this).attr("name")); | |
}); | |
var clone = $(this).clone(withDataAndEvents); | |
clone.find("*").each(function(){ | |
$(this).attr("name", $(this).data("name")); | |
}); | |
return clone; | |
} | |
}; | |
})(jQuery); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from django.db import models | |
from Crypto.Cipher import AES | |
class Mode(models.Model): | |
mode_name = models.CharField(max_length=56) | |
def __unicode__(self): | |
return u'%s' % self.mode_name | |
class TransitMode(models.Model): | |
mode = models.ForeignKey(to='Mode') | |
transit =models.ForeignKey(to='TransitBenefit') | |
cost = models.IntegerField() | |
class TransitBenefit(models.Model): | |
name = models.CharField(max_length=56) | |
email = models.EmailField() | |
amount = models.IntegerField() | |
modes = models.ManyToManyField(to=Mode,through='TransitMode') | |
def __unicode__(self): | |
return u'%s %s <%s>' % (self.first_name, self.last_name,self.email) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
This file demonstrates writing tests using the unittest module. These will pass | |
when you run "manage.py test". | |
Replace this with more appropriate tests for your application. | |
""" | |
from django.test import TestCase | |
from django.http import QueryDict | |
import views | |
from models import Mode,TransitBenefit,TransitMode | |
class TransitModelTests(TestCase): | |
fixtures = ['transit_modex.json'] | |
def test_build_simple_transit_benefit(self): | |
t = TransitBenefit() | |
t.name ='bill' | |
t.email='bill@if.io' | |
t.amount=100 | |
m1 = Mode.objects.create('taxi') | |
_modes = Mode.objects.create( mode=m1,transit=t,cost=50 ) | |
class TransitViewTests(TestCase): | |
def test_that_segments_are_parsed(self): | |
request = QueryDict('') | |
query_string = 'segment_type_0=dash&segment_cost_0=1&segment_type_1=metro&segment_cost_1=3&segment_type_2=vre&segment_cost_2=5&segment_type_3=eerie&segment_cost_3=7&segment_type_4=dillon&segment_cost_4=9' | |
request.POST = QueryDict(query_string) | |
actual = views.get_segments(request) | |
self.assertEqual( u'1', actual['dash']) | |
self.assertEqual( u'3', actual['metro']) | |
self.assertEqual( u'5', actual['vre']) | |
self.assertEqual( u'7', actual['eerie']) | |
self.assertEqual( u'9', actual['dillon']) | |
print actual | |
def test_that_disparate_segments_are_ignored(self): | |
request = QueryDict('') | |
query_string = 'foo=bar&asd=asd&segment_type_0=metro&segment_cost_0=180&x=z' | |
request.POST = QueryDict(query_string) | |
actual = views.get_segments(request) | |
self.assertEqual( u'180', actual['metro']) | |
print actual |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from django.utils.unittest.case import skipIf | |
from django.db.models import Avg, Max, Min, Count, Sum | |
from decimal import * | |
from datetime import datetime | |
from django.test import TestCase | |
from front.models import OfficeLocation | |
from django.contrib.auth.models import User | |
from transit_subsidy.models import TransitSubsidy,TransitSubsidyForm,Mode,TransitSubsidyModes | |
from front.models import Person | |
class TransportationSubsidyMany2ManyTest(TestCase): | |
fixtures = ['offices.json','transit_modes.json'] | |
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
def setUp(self): | |
""" | |
Assumes valid user | |
""" | |
self.user = User.objects.create_user('test_user','test_user@user.name','password') | |
is_logged_in = self.client.login(username='test_user',password='password') | |
self.assertTrue(is_logged_in, 'Client not able to login?! Check fixture data or User creation method in setUp.') | |
self.office = OfficeLocation.objects.order_by('city')[0] | |
self.modes = Mode.objects.all() | |
def tearDown(self): | |
pass | |
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | |
# --- To Do: Refactor to model_form_tests | |
def test_create_simple_transit_subsidy(self): | |
trans = self._set_transit_subsidy() | |
trans.save() | |
#Metro | |
_modes = TransitSubsidyModes(transit_subsidy=trans, mode=self.modes[0], cost=100) | |
_modes.save() | |
#Dash | |
_modes = TransitSubsidyModes(transit_subsidy=trans, mode=self.modes[1], cost=50) | |
_modes.save() | |
ts_modes = TransitSubsidyModes.objects.all() | |
actual = ts_modes.aggregate( Sum('cost') ) | |
print actual['cost__sum'] | |
self.assertEquals( Decimal('150.00'), actual['cost__sum'] ) | |
def _set_transit_subsidy(self): | |
transit = TransitSubsidy() | |
office = OfficeLocation.objects.order_by('city')[0] | |
transit.user = self.user | |
transit.destination = office | |
transit.date_enrolled = '2011-06-23' | |
transit.origin_street = '123 Main Street' | |
transit.origin_city = 'Anytown' | |
transit.origin_state = 'VA' | |
transit.origin_zip = '22222' | |
transit.route_description = "Harbor bus to City Subway stop to Work Subway" | |
transit.number_of_workdays = 20 | |
transit.daily_roundtrip_cost = 8 | |
transit.daily_parking_cost = 4 | |
transit.amount = 8 | |
transit.dc_wmta_smartrip_id = '123-123-123' | |
transit.terms_of_service = True | |
transit.supervisor_approval = True | |
return transit | |
# transit.save() | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from django.template import RequestContext, loader | |
from django.shortcuts import render_to_response | |
from django.core import serializers | |
from django.http import HttpResponse | |
from smartrip.models import * | |
from dynamicresponse.response import * | |
from django.views.decorators.csrf import csrf_exempt | |
from models import TransitBenefit | |
def get_segments(request): | |
segments = {} | |
for name in request.POST: | |
if name.startswith('segment_type'): | |
name_key = request.POST[name] | |
cost_key = name.replace('type', 'cost') | |
segments[name_key] = request.POST[cost_key] | |
return segments | |
@csrf_exempt | |
def ajax(request): | |
if request.method == 'POST': | |
segments = get_segments(request) | |
# Save model then segments? | |
return HttpResponse('ok-Django says, got request. %s' % str(request.raw_post_data) ) #segments | |
else: | |
render_to_response('index.html') | |
def home(request): | |
tb = TransitBenefit() | |
tb.first_name = 'ed' | |
tb.last_name = 'last name' | |
tb.email = 'ed@ed.com' | |
tb.last_four_ssn = '1234' | |
tb.amount = 200 | |
tb.date_requested = '2011-09-07' | |
#tb.save() | |
return render_to_response('index.html') | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment