Skip to content

Instantly share code, notes, and snippets.

@dealproc
Last active October 21, 2022 18:45
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save dealproc/6678280 to your computer and use it in GitHub Desktop.
Save dealproc/6678280 to your computer and use it in GitHub Desktop.
/// <reference path="./jquery-1.10.2-vsdoc.js" />
/// <reference path="./jquery-ui-1.10.3.js" />
/// <reference path="./jquery.jqGrid.src.js" />
window.jqGridSettings = {};
function bindConfiguration(key, options) {
var p = window.jqGridSettings[options.idPrefix];
if (p === null || p === undefined) {
// do init work here.
p = $.extend(true, {
cols: [
],
gridTitle: "",
idPrefix: "grid",
containerId: "gridContainer",
exportUrl: "/Export/Excel",
oDataEndPoint: "/odata/States",
useVirtualScrolling: 1,
gridHeight: 500,
windowResizeDelay: -1, // windowResizeDelay is a marker to be able to clear the setTimeout function in case the window is still being resized.
quoteFilters: [],
container: null,
form: null,
filenameField: null,
oDataField: null,
filterField: null,
orderByField: null,
list: null,
table: null,
pager: null,
multiSelect: false,
onRowSelection: undefined,
onPageChange: undefined,
onGridReload: undefined,
onAllSelection: undefined,
onSortColumnClick: undefined
}, options || {});
p.quoteFilters = getQuoteFlags(p.cols);
// this is the container on the html page where the grid will be rendered.
p.container = $("#" + p.containerId);
// this form (and the four elements below) are what allow us to do the server-side rendering of the excel file.
p.form = document.createElement("form");
p.form.id = p.idPrefix + "-form";
p.form.method = "POST";
p.form.action = p.exportUrl;
document.body.appendChild(p.form);
// filename
p.filenameField = document.createElement("input");
p.filenameField.type = "hidden";
p.filenameField.id = p.idPrefix + "-filename";
p.filenameField.name = "Filename";
p.filenameField.value = "";
p.form.appendChild(p.filenameField);
// ODataUrl
p.oDataField = document.createElement("input");
p.oDataField.type = "hidden";
p.oDataField.id = p.idPrefix + "-ODataUrl";
p.oDataField.name = "ODataUrl";
p.oDataField.value = p.oDataEndPoint;
p.form.appendChild(p.oDataField);
// Filter
p.filterField = document.createElement("input");
p.filterField.type = "hidden";
p.filterField.id = p.idPrefix + "-Filter";
p.filterField.name = "Filter";
p.filterField.value = "";
p.form.appendChild(p.filterField);
// OrderBy
p.orderByField = document.createElement("input");
p.orderByField.type = "hidden";
p.orderByField.id = p.idPrefix + "-OrderBy";
p.orderByField.name = "OrderBy";
p.orderByField.value = "";
p.form.appendChild(p.orderByField);
// The grid definition. It takes 3 components:
// a div for the "list"
p.list = document.createElement("div");
p.list.id = p.idPrefix + "-list";
p.container.append(p.list);
p.table = document.createElement("table");
p.table.id = p.idPrefix + "-table";
p.container.append(p.table);
p.pager = document.createElement("div");
p.pager.id = p.idPrefix + "-pager";
p.container.append(p.pager);
window.jqGridSettings[p.idPrefix] = p;
}
return p;
}
function generateGrid(options) {
var oKey = options.idPrefix || "grid";
// our default options... the expectation is that the end developer sets all of these in the *.html code.
var p = bindConfiguration(oKey, options);
// building the grid here.
var grid = $(p.table).jqGrid({
url: p.oDataEndPoint,
datatype: "json",
height: p.gridHeight,
autowidth: true, // allows the grid to expand to the max. width given its parent container.
pager: "#" + p.pager.id,
viewrecords: true,
caption: p.gridTitle,
gridview: true,
// allows the user to sort by multiple columns. the catch here is that the column ordering in the grid sets the prescedence for the
// hierarchy of the sort.. so if you have id first, then date, then name, it's going to sort by id, date, and name. to sort by date,
// then name, the user will need to drag the "name" field to the first position, then the "date" field to the second position, then
// click the columns to get the ordering (asc/desc) that they wish.
multiSort: true,
sortable: true, // allows the user to sort the ordering of the columns.
colNames: getHeaders(p.cols),
colModel: getColumDefinitions(p.cols),
rowNum: 50,
rowList: [10, 25, 50, 75, 100],
multiselect: p.multiSelect,
scroll: p.useVirtualScrolling, // turns on/off scrolling vs. paging.
onSelectRow: p.onRowSelection, // Added by Sarthak Joshi : Allows event to be triggered after a particular row is selected [Note : onSelectRow is original jQGrid event]
onPaging: p.onPageChange,
gridComplete: p.onGridReload, // Added by Sarthak Joshi : Allows event to be triggered after grid is reloaded when paging is enabled [Note : loadComplete is original jQGrid event]
onSelectAll: p.onAllSelection, // Added by Sarthak Joshi : Allows event to be triggered after select all checkbox is checked when MultiSelect is true [Note : onSelectAll is original jQGrid event]
onSortCol: p.onSortColumnClick, // Added by Sarthak Joshi : Allows event to be triggered when sortable column header is clicked [Note : onSortCol is original jQGrid event]
ajaxGridOptions: {
contentType: "application/json charset=utf-8"
},
serializeGridData: function (postData) { return setupWebServiceData(postData, p); },
beforeProcessing: function (data, textStatus, jqXHR) {
// builds out the total page count for the data returned.
var rows = parseInt($(this).jqGrid("getGridParam", "rowNum"), 10);
data.total = Math.ceil(data["Count"] / rows); // change to odata.count if using Odata API
},
jsonReader: {
root: "Items", // the root node of the Json, change to value for OData api.
repeatitems: false, // tells the grid to find the data by property name.
records: "Count" // the path to get the record count., change to OData.count for OData api.
},
loadError: function (jqXHR, textStatus, errorThrown) {
alert('HTTP status code: ' + jqXHR.status, +'\n' +
'textStatus: ' + textStatus + '\n' +
'errorThrown: ' + errorThrown);
}
});
buildNavigation(grid);
var parentContainer = $("#" + p.containerId);
$("#sidenav-flyout-btn").on("click", function () {
setTimeout(function () {
p.windowResizeDelay = beginResize(grid, parentContainer, p.windowResizeDelay);
}, 200);
});
$(window).resize(function (event, ui) {
p.windowResizeDelay = beginResize(grid, parentContainer, p.windowResizeDelay);
});
return grid;
};
function setupWebServiceData(postData, p) {
// basic posting parameters to the OData service.
var params = {
$top: postData.rows,
$skip: (parseInt(postData.page, 10) - 1) * postData.rows,
$inlinecount: "allpages"
};
// if we have an order-by clause to use, then we build it.
if (postData.sidx) {
// two columns have the following data:
// postData.sidx = "{ColumnName} {order}, {ColumnName} "
// postData.sord = "{order}"
// we need to split sidx by the ", " and see if there are multiple columns. If there are, we need to go through
// each column and get its parts, then parse that for the appropriate columns to build for the sort.
var splitColumnOrdering = (postData.sidx + postData.sord).split(", ");
if (splitColumnOrdering.length == 1) {
params.$orderby = buildColumnSort(splitColumnOrdering[0], quoteFilters);
} else {
var colOrdering = $.map(splitColumnOrdering, function (element, idx) {
return buildColumnSort(element, quoteFilters);
});
params.$orderby = colOrdering.join(", ");
}
}
// if we want to support "in" clauses, we need to follow this stackoverflow article:
//http://stackoverflow.com/questions/7745231/odata-where-id-in-list-query/7745321#7745321
// this is for basic searching, with a single term.
if (postData.searchField) {
var quoteFilter = findQuoteFilter(postData.searchField, quoteFilters);
params.$filter = ODataExpression(postData.searchOper, postData.searchField, postData.searchString, quoteFilters);
}
// complex searching, with a groupOp. This is for if we enable the form for multiple selection criteria.
if (postData.filters) {
var filterGroup = $.parseJSON(postData.filters);
params.$filter = parseFilterGroup(filterGroup, p.quoteFilters);
}
// sets the form elements with the filter/group parameters, so that the user can
// export the data to excel if they so choose.
$("#" + p.idPrefix + "-Filter").val(params.$filter);
$("#" + p.idPrefix + "-OrderBy").val(params.$orderby);
return params;
}
function buildNavigation(grid) {
var key = grid.context.id.split("-")[0];
var p = bindConfiguration(key, { idPrefix: key });
grid.navGrid("#" + p.pager.id, { search: true, edit: false, add: false, del: false },
{}, // default settings for edit
{}, // default settings for add
{}, // delete
{closeOnEscape: true, multipleSearch: true, closeAfterSearch: true, multipleGroup: true} // search options.
);
// adds a little space between buttons
grid.navSeparatorAdd("#" + p.pager.id, { sepclass: "", sepcontent: " " })
// creates the "Save" button.
grid.jqGrid("navButtonAdd", "#" + p.pager.id, {
caption: "Save Filter",
buttonicon: "none",
onClickButton: function () { showSaveFilterUI(p); },
position: "last",
title: "Save Filter",
cursor: "pointer"
});
// adds a little space between buttons
grid.navSeparatorAdd("#" + p.pager.id, { sepclass: "", sepcontent: " " });
// creates the "Save" button.
grid.jqGrid("navButtonAdd", "#" + p.pager.id, {
caption: "Load Filter",
buttonicon: "none",
onClickButton: function () { showLoadFilterUI(p, p.pager); },
position: "last",
title: "Load Filter",
cursor: "pointer"
});
// adds a little space between buttons
grid.navSeparatorAdd("#" + p.pager.id, { sepclass: "", sepcontent: " " });
// Creates the "Excel" button.
grid.jqGrid("navButtonAdd", "#" + p.pager.id, {
caption: "Excel",
buttonicon: "none",
onClickButton: function () { showExportExcelUI(p); },
position: "last",
title: "Export to Excel",
cursor: "pointer"
});
};
// sets up resizing the grid in the event that the user shows/hides navigation, or
// resizes the window.
function beginResize(grid, container, delay) {
if (delay !== -1) {
clearTimeout(delay);
delay = -1;
}
delay = setTimeout(function () {
var newWidth = container.width();
grid.setGridWidth(newWidth, true);
delay = -1;
}, 100);
return delay;
}
// tools to load the settings
function showLoadFilterUI(p, pager) {
$.get("/GridFilters/Load/?prefix=" + p.idPrefix)
.then(function (htmlPartial) {
var loadFilterDialog = $("<div>" + htmlPartial + "</div>").dialog({
title: "Load Saved Filter...",
height: 150,
width: 300,
modal: true,
buttons: [{
text: "Ok",
click: function () {
var grid = $("#" + p.idPrefix + "-table");
var dd = $("#gridFilterSelection");
$.get("/api/GridFilters/" + dd.val())
.then(function (data) {
loadFilterDialog.dialog("close"); // should get rid of dailog here.
//$("#" + p.table.id).jqGrid("getGridParam", "postData"); gives the search/sort params.
//$("#" + p.table.id).jqGrid("getGridParam", "colModel"); do a grep/each and take the index for saving.
//$("#" + p.table.id).jqGrid("getGridParam", "colNames"); titles of each column.
//$("#" + p.table.id).jqGrid("remapColumns", newOrder, true, true); reorders the columns (and headers)
var options = $.parseJSON(data);
// reorder the columns.
var gridCols = grid.jqGrid("getGridParam", "colModel");
var newOrder = $.map(options.colOrder, function (arg, idx) {
var colObj = $.grep(gridCols, function (col, colIdx) {
return col.index === arg;
})[0];
var columnIndex = gridCols.indexOf(colObj);
return columnIndex;
});
// set the post data.
var gridPostData = grid.jqGrid("getGridParam", "postData");
gridPostData.filters = options.postData.filters;
gridPostData.rows = options.postData.rows;
gridPostData.page = options.postData.page;
gridPostData.sidx = options.postData.sidx;
gridPostData.sord = options.postData.sord;
grid.jqGrid("remapColumns", newOrder, true, false);
grid.trigger("reloadGrid");
//grid.jqGrid("remapColumns", newOrder, true, true);
}, function (data) {
alert("An error occurred?");
});
}
}, {
text: "Cancel",
click: function () {
$(this).dialog("close");
}
}],
close: function (event, ui) {
$(this).dialog("destroy");
$(this).remove();
}
});
});
};
function showExportExcelUI(p) {
// the dialog to prompt the user for the respective file name.
$("<div></div>").dialog({
title: "Save As...",
height: 150,
width: 300,
modal: true,
buttons: [{
text: "Ok",
click: function () {
// copy the value from the dialog's text box to the form to be sumbitted's field to hold the file name.
var formFileField = $("#" + p.idPrefix + "-filename");
var dialogValue = $("#" + p.idPrefix + "-dlgFilename");
formFileField.val(dialogValue.val());
if (formFileField.val()) {
p.form.submit();
$(this).dialog("close");
} else {
alert("File name was not provided!");
}
}
}, {
text: "Cancel",
click: function () {
// closes the file.
$(this).dialog("close");
}
}],
close: function (event, ui) {
// desconstructs the Dailog and removes the html elements that are added to the page.
$(this).dialog("destroy");
$(this).remove();
}
}).html("<label for=\"" + p.idPrefix + "-dlgFilename\">Filename:</label>" +
"<input type=\"text\" id=\"" + p.idPrefix + "-dlgFilename\" style=\"float: right; width: 175px;\" />");
}
// builds the grid search/filter export settings ui.
function showSaveFilterUI(p) {
$.get("/GridFilters/Save?prefix=" + p.idPrefix).then(function (data) {
var dialog = $("<div id='settingsExportDialog'>" + data + "</div>").dialog({
title: "Export Grid Settings...",
modal: true,
width: 500,
height: 300,
close: function () {
$(this).dialog("destroy");
$(this).remove();
},
buttons: [
{
text: "Save",
click: function (event, ui) {
var form = $($("#gridFilterSaveForm")[0]);
var settings = $("#gridFilterSaveForm input[name=Settings]");
var colIds = $.map($("#" + p.table.id).jqGrid("getGridParam", "colModel"), function (element, index) {
return element.index;
});
var options = {
postData: $("#" + p.table.id).jqGrid("getGridParam", "postData"),
colOrder: colIds
};
settings.val(JSON.stringify(options));
$.post(form.context.action, form.serialize(), function (data) {
dialog.html(data);
});
}
},
{
text: "Close",
click: function (event, ui) {
$(this).dialog("close");
}
}
]
});
});
}
// builds the column headers array from the Json we pass to the
// main grid builder class.
function getHeaders(cols) {
return $.map(cols, function (element, idx) {
return element.headerTitle;
});
}
// builds the jqGrid column definition from the data we pass. We need
// to alter for dates that I can think of, and need to discuss other data
// types to decide what we'd want to do for each (specifically the search options.)
function getColumDefinitions(cols) {
return $.map(cols, function (element, idx) {
var ele = $.extend(true, {
allowSearch: true
}, element || {});
var col = {
name: ele.fieldName,
index: ele.fieldName,
search: ele.allowSearch
};
if (ele.formatter) {
col = $.extend(true, { formatter: ele.formatter }, col || {});
}
if (ele.width) {
col = $.extend(true, { width: ele.width }, col || {});
}
switch (ele.dataType) {
case "hidden":
col = $.extend(true, {
sortable: false,
hidden: true,
searchoptions: { sopt: ['eq', 'ne', 'lt', 'le', 'gt', 'ge', 'bw', 'bn', 'ew', 'en', 'cn', 'nc', 'nu', 'nn'], searchhidden: true }
}, col || {});
break;
case "hidden-number":
col = $.extend(true, {
sortable: false,
hidden: true,
searchoptions: { sopt: ['eq', 'ne', 'lt', 'le', 'gt', 'ge'], searchhidden: true }
}, col || {});
break;
case "number":
col = $.extend(true, {
sortable: true,
search: ele.allowSearch,
searchoptions: { sopt: ['eq', 'ne', 'lt', 'le', 'gt', 'ge'] },
cellattr: function (rowId, tv, rawObject, cm, rdata) {
return 'style="vertical-align: middle;"';
}
}, col || {});
break;
case "link":
col = $.extend(true, {
sortable: false,
formatter: ele.linkFormatter,
cellattr: function (rowId, tv, rawObject, cm, rdata) {
return 'style="vertical-align: middle;"';
}
}, col || {});
col.search = false;
break;
case "boolean":
col = $.extend(true, {
sortable: true,
cellattr: function (rowId, tv, rawObject, cm, rdata) {
return 'style="vertical-align: middle;"';
},
stype: "select",
formatter: function (cellvalue, options, rowObject) {
return cellvalue ? "Enabled" : "Disabled";
},
searchoptions: { sopt: ['eq', 'ne'], value: "bool-true:Enabled;bool-false:Disabled" }
}, col || {});
break;
case "list-number":
case "list":
var searchops = { sopt: ["eq"] };
if (ele.searchItems !== undefined) {
searchops = $.extend(true, { value: ele.searchItems }, searchops || {});
};
col = $.extend(true, {
sortable: false,
cellattr: function (rowId, tv, rawObject, cm, rdata) {
return 'style="white-space: normal; vertical-align: middle;"';
},
stype: "select",
searchoptions: searchops
}, col || {});
break;
case "lookup":
var searchOps = { sopt: ["eq"] };
if (ele.searchItems !== undefined) {
searchOps = $.extend(true, { value: ele.searchItems }, searchOps || {});
};
col = $.extend(true, {
sortable: true,
cellattr: function (rowId, tv, rawObject, cm, rdata) {
return 'style="white-space: normal; vertical-align: middle;"';
},
stype: "select",
searchoptions: searchOps
}, col || {});
break;
default:
col = $.extend(true, {
sortable: true,
searchoptions: { sopt: ['eq', 'ne', 'lt', 'le', 'gt', 'ge', 'bw', 'bn', 'ew', 'en', 'cn', 'nc', 'nu', 'nn'] },
searchrules: {},
cellattr: function (rowId, tv, rawObject, cm, rdata) {
return 'style="white-space: normal; vertical-align: middle;"';
}
}, col || {});
break;
}
return col;
});
}
// when dealing with the advanced query dialog, this parses the encapsulating Json object
// which we will then build the advanced OData expression from.
function parseFilterGroup(filterGroup, filters) {
var filterText = "";
if (filterGroup.groups) {
if (filterGroup.groups.length) {
for (var i = 0; i < filterGroup.groups.length; i++) {
filterText += "(" + parseFilterGroup(filterGroup.groups[i]) + ")";
if (i < filterGroup.groups.length - 1) {
filterText += " " + filterGroup.groupOp.toLowerCase() + " ";
}
}
if (filterGroup.rules && filterGroup.rules.length) {
filterText += " " + filterGroup.groupOp.toLowerCase() + " ";
}
}
}
if (filterGroup.rules.length) {
// fields that are considered as a list should get built as a single
// odata expression.
var listFields = $.grep(filterGroup.rules, function (rule, idx) {
var foundFilter = findQuoteFilter(rule.field, filters);
if (foundFilter.isList !== undefined) {
return foundFilter.isList;
}
return false;
});
var allListNames = $.map(listFields, function (rule, idx) {
return rule.field;
});
var distinctFieldNames = $.unique(allListNames);
$.each(distinctFieldNames, function (idx, fieldName) {
var fieldValues = $.grep(listFields, function (fieldValue, idx) {
return fieldValue.field === fieldName;
});
var fieldDataValues = $.map(fieldValues, function (rule, idx) {
return rule.data;
});
var foundFilter = findQuoteFilter(fieldName, filters);
filterText += ODataListExpression(filterGroup.groupOp.toLowerCase(), fieldName, fieldDataValues, foundFilter);
});
var elementFields = $.grep(filterGroup.rules, function (rule, idx) {
var foundFilter = findQuoteFilter(rule.field, filters);
if (foundFilter.isList !== undefined) {
return !foundFilter.isList;
}
return true;
});
for (var i = 0; i < elementFields.length; i++) {
var rule = filterGroup.rules[i];
var filter = findQuoteFilter(rule.field, filters);
filterText += ODataExpression(rule.op, rule.field, rule.data, filter);
if (i < filterGroup.rules.length - 1) {
filterText += " " + filterGroup.groupOp.toLowerCase() + " ";
}
}
}
return filterText;
}
// comparer should be a value of 'and' or 'or'.
// quoteFlags = { col: element.fieldName, quoteValue: false, isList: true, baseQuery: element.baseQuery, baseQueryParam: element.baseQueryParam };
function ODataListExpression(comparer, field, dataItems, filter) {
var quoteData = $.grep(filter, function (element, idx) {
return element.col === field;
});
var params = $.map(dataItems, function (element, idx) {
var param = quoteDataVal(element, filter);
return filter.baseQueryParam.replace("{0}", param);
});
var paramstring = params.join(" " + comparer + " ");
return filter.baseQuery.replace("{0}", paramstring);
}
// builds out OData expressions... the condition.
function ODataExpression(op, field, data, filter) {
var dataVal = quoteDataVal(data, filter);
// lists are a unique concern. with lists, we have to provide an xml/json path
// for OData to query against. The best way to handle this is to define the base
// path query within each Index() page, and use that here with some sort of string.replace
// or string.format javascript function.
switch (op) {
case "cn":
return "substringof(" + dataVal + ", " + field + ") eq true";
case "nc": // does not contain.
return "substringof(" + dataVal + ", " + field + ") eq false";
case "bw":
return "startswith(" + field + ", " + dataVal + ") eq true";
case "bn": // does not begin with
return "startswith(" + field + ", " + dataVal + ") eq false";
case "ew":
return "endswith(" + field + ", " + dataVal + ") eq true";
case "en": // does not end with.
return "endswith(" + field + ", " + dataVal + ") eq false";
case "nu":
return field + " eq null";
case "nn":
return field + " ne null";
default:
return field + " " + op + " " + dataVal;
}
};
/// cols is an array.
function getQuoteFlags(cols) {
return $.map(cols, function (element, idx) {
//sortCols
var quoteFlags;
switch (element.dataType) {
case "hidden":
quoteFlags = { col: element.fieldName, quoteValue: true, isList: false };
break;
case "hidden-number":
quoteFlags = { col: element.fieldName, quoteValue: false, isList: false };
break;
case "number":
quoteFlags = { col: element.fieldName, quoteValue: false, isList: false };
break;
case "link":
quoteFlags = { col: element.fieldName, quoteValue: false, isList: false };
break;
case "boolean":
quoteFlags = { col: element.fieldName, quoteValue: false, isList: false };
break;
case "list-number":
quoteFlags = { col: element.fieldName, quoteValue: false, isList: true, baseQuery: element.baseQuery, baseQueryParam: element.baseQueryParam };
break;
case "list":
quoteFlags = { col: element.fieldName, quoteValue: true, isList: true, baseQuery: element.baseQuery, baseQueryParam: element.baseQueryParam };
break;
default:
quoteFlags = { col: element.fieldName, quoteValue: true, isList: false };
break;
};
if (element.sortCols !== undefined) {
quoteFlags = $.extend(true, {
sortCols: element.sortCols
}, quoteFlags || {});
};
return quoteFlags;
});
};
// primarily used for the bid text code, the idea here is that we
// can do the sorting for numerics and codes so that all of the 13's
// are grouped together for the UI.
function buildColumnSort(gridColCommand, filters) {
var parts = gridColCommand.split(" ");
if (parts.length !== 2) {
throw new Error("Cannot build a sort command without the column name and the direction.");
}
var col = parts[0];
var direction = parts[1];
if (col === "" || direction === "") {
throw new Error("We need to know both the column name and the sort direction.");
}
var quoteData = $.grep(filters, function (element, idx) {
return element.col === col;
});
if (quoteData.length === 0) {
// if we don't have a definition for the field, then we can't filter/search for it.
return "";
};
quoteData = quoteData[0];
if (quoteData.sortCols !== undefined) {
var colSorts = [];
for (var i = 0; i < quoteData.sortCols.length; i++) {
colSorts.push(quoteData.sortCols[i] + " " + direction);
};
return colSorts.join(", ");
}
return col + " " + direction;
}
function quoteDataVal(data, filter) {
if (filter.quoteValue) {
return "'" + data + "'";
}
return data;
}
function findQuoteFilter(field, filters) {
var quoteFilter = $.grep(filters, function (element, idx) {
return element.col === field;
});
if (quoteFilter.length === 0) {
throw new Error("Cannot find appropriate quote filter for field: " + field);
};
return quoteFilter[0];
}
@dealproc
Copy link
Author

allows usage of jqGrid to query and process an OData endpoint.

@telefonosuci
Copy link

Hi, it looks very interesting :)

Did you write any tutorial to use this extension ?

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