/**
*
* @class A Client service that provides widgets that can be embedded into views.
*
* @copyright (c) 2021 TLF Research Ltd.
*
*/
function Client() {
const self = this;
this.NULL = "__null__";
this.Globals = {};
this.widgets = {
static: {},
disposable: {},
};
this.filters = {
suppressAllFilterEvents: false,
loaded: false,
ready: {},
hooks: [],
};
// Set chart library culture to UK
kendo.culture("en-GB");
// Handle browser history
window.onpopstate = function (e) {
if (e.state) {
self.switchToView(e.state.view, false);
}
};
/**
* Defines a standard Kendo dataSource that can be used by most widgets
*
* @function widgetSimpleDataSource
* @memberof Client
*
*/
this.widgetSimpleDataSource = {
transport: {
read: {
dataType: "json",
xhrFields: {
withCredentials: true,
},
},
},
schema: {
data: "rows",
parse: server.parseResponse,
},
};
/**
* Sets up the standard client environment.
*
* @param {Function} onReady method to be invoked when the middleware layer has initialised.
*
* @function init
* @memberof Client
*/
this.init = function (onReady) {
// Add required globals
client.Globals = {
project: {},
now: new Date(),
lastDataPointDate: null,
user: null,
data: {},
};
// Fetch base data from the API
$.when(server.getInfo(Server.NO_CACHE), server.getUser(Server.NO_CACHE)).done(function (
serverInfo,
userInfo
) {
const user = userInfo[0];
self.Globals.user = user;
const userActions = {
// Logout action is always allowed
logout: {
label: "Logout",
onClick: client.logout,
},
};
// Add user actions based on their roles
if (user.roles) {
if (user.roles.includes("manage_users")) {
userActions["users"] = {
label: "Users",
onClick: client.manageUsers,
};
}
if (user.roles.includes("manage_realm")) {
userActions["users"] = {
label: "Realm",
onClick: client.manageRealm,
};
}
if (user.roles.includes("api_invoke_debug")) {
userActions["users"] = {
label: "Debug Mode",
onClick: client.setDebug,
};
}
}
// Create a user menu widget
client.addUser("user", {
user: user,
actions: userActions,
});
user.roles = user.roles || [];
user.defaultView = getDefaultView();
server.Globals = serverInfo[0];
// Get and cache metadata about the first relevant project in the User's profile
const applicableProjects = client.getApplicableProjects(user.projects);
// Warn user if there is no project data to display
if (applicableProjects.every(p => !p)) {
console.warn("No applicable project data to display");
return;
}
client.Globals.projects = [...applicableProjects];
// Build a map by id of the project's index in the applicableProjects array
const projectMap = {};
applicableProjects.forEach((p, idx) => {
if (p) projectMap[p.id] = idx
});
// Strip all null projects from the list before we try to fetch them
const validProjects = applicableProjects.filter(p => p);
server
.getProjects(Convert.extractFromArray(validProjects, "id"))
.done(function () {
// If there's only one request then the arguments array consists solely of 3 elements containing the XHR response.
// But, if there's more than one request, the arguments array is an array of tuples with each containing the appropriate XHR response.
// This code recognises the 1-request scenario by looking for a single XHR request signature.
// If found, it normalizes the result by copying it into a single element array.
const results = arguments[1] === "success" ? [arguments] : arguments;
for (let a = 0; a < results.length; a++) {
const proj = results[a][0];
const dateVars = client.getProjectVarsByCategory(proj, "response_date", "name");
if (dateVars.length !== 1) {
console.error("There must be exactly ONE variable in the " + proj.name + " project allocated to the 'response_date' category");
} else {
proj.dateVar = dateVars[0];
}
const indexVars = client.getProjectVarsByCategory(proj, "index", "name");
if (indexVars.length === 1) {
proj.indexVar = indexVars[0];
}
// Replace the project object from Keycloak in client.Globals.projects with the full project from WR3
client.Globals.projects[projectMap[proj.id]] = proj;
}
if (typeof onReady === "function") {
onReady.call(client);
}
// Check if we need to inject support for a Tour event
if (config.TOUR && !client.getCookie("suppress-tour")) {
client.injectCSS("/css/tour.css", () => {
new Tour(config.TOUR);
});
}
})
.fail(function (jqXHR) {
if (jqXHR.status === 401) {
$("body").addClass("not-logged-in");
}
});
});
};
/**
* Function to get a cookie by name.
*
* @param {string} name Name of the cookie value to retrieve.
*
* @function getCookie
* @memberof Client
*/
this.getCookie = function (name) {
const cookies = document.cookie.split(';').map(cookie => cookie.trim());
for (const cookie of cookies) {
const [cookieName, cookieValue] = cookie.split('=');
if (cookieName === name) {
return decodeURIComponent(cookieValue);
}
}
return null;
}
/**
* Function to set a cookie by name.
*
* @param {string} name Name of the cookie value to set.
* @param {string} value New value.
*
* @function setCookie
* @memberof Client
*/
this.setCookie = function (name, value, daysToExpire, path = '/') {
if (typeof name !== 'string' || typeof value !== 'string') {
throw new Error('Cookie name and value must be strings.');
}
if (daysToExpire && typeof daysToExpire !== 'number') {
throw new Error('Expiration days must be a number.');
}
const expires = daysToExpire ? `; expires=${new Date(Date.now() + daysToExpire * 86400000).toUTCString()}` : '';
document.cookie = `${name}=${encodeURIComponent(value)}; path=${path}${expires}`;
}
/**
* Function to delete a cookie by name.
*
* @param {string} name Name of the cookie to delete.
*
* @function deleteCookie
* @memberof Client
*/
this.deleteCookie = function (name) {
const expirationDate = new Date(0).toUTCString();
document.cookie = `${name}=; expires=${expirationDate}`;
}
/**
* Adds additional Global variables
*
* @param {object} glob Additional variables to be added to the client.Globals object.
*/
this.addGlobals = function (glob) {
$.extend(this.Globals, glob);
};
/**
* Adds any user claims that start with 'filter_' to the given filters object and returns a new filter object including the new filters.
* The original filters are not modified.
*
* @param {object} filters Original filters object. They are not modified.
*/
this.addSoftFilters = function (filters) {
if (!filters) return filters;
const newFilters = { ...filters };
const claims = client.Globals.user.claims;
for (let claimName in claims) {
if (claimName.startsWith('filter_')) {
const varName = claimName.slice(7);
newFilters[varName] = `{${varName}} IN("${claims[claimName].join('","')}")`;
}
}
return newFilters;
};
/**
* Returns a new set of rows that have been filtered by any user claims that start with 'filter_'.
*
* @param {array} rows Original rows object. It is not modified.
*/
this.softFilterRows = function (rows) {
if (!rows) return rows;
const claims = client.Globals.user.claims;
const filter = {};
for (let claimName in claims) {
if (claimName.startsWith('filter_')) {
const varName = claimName.slice(7);
filter[varName] = claims[claimName];
}
}
return rows.filter(row => {
return Object.keys(filter).every(key => {
const rowValue = row[key];
const filterValue = filter[key];
// Always match a rolled-up result
if (row[`_aggr_${key}`]) {
return true;
}
// If the filter value is an array, check whether any of its elements match the row value
if (Array.isArray(filterValue)) {
return filterValue.includes(rowValue);
}
// Otherwise, compare the filter value directly to the row value
return rowValue === filterValue;
});
});
};
/**
* Logs the user out.
*/
this.logout = function () {
document.body.dispatchEvent(new Event("beforeLogout"));
window.location.href =
window.location.origin +
"/_callback?logout=" +
encodeURIComponent(window.location.origin + "/status/logged_out.html");
};
/**
* Loads a view that provides some basic user management features.
*/
this.manageUsers = function () {
self.switchToView("manage-users", false);
};
/**
* Redirects to the Keycloak console to allow the realm to be managed via the IDP.
*/
this.manageRealm = function () {
window.location.href = config.IDP + "/auth/admin/" + config.REALM + "/console/";
};
/**
* Sets debug mode on
*/
this.setDebug = function () {
const params = new URL(document.location).searchParams;
params.append("debug", true);
window.location.href = window.location.origin + "?" + params.toString();
};
/**
* Allows the user to request a password reset.
*/
this.resetPassword = function () {
window.location.href = window.location.origin + "/identity/person/passwordReminder.htm";
};
/**
* Returns a collection of Project Variables properties for variables that are in *any* of the given categories.
* If props is a string a sinple array of scalar values is returned, otherwise it's an array of objects.
*
* @param {object} project Project to inspect
* @param {array|string} categories Categories to inspect (null = get all categories)
* @param {array|string} props Variable properties to return (the id property is always returned)
*
* @returns {array} Project variables in the given categories.
*/
this.getProjectVarsByCategory = function (project, categories, props) {
if (!project) {
console.error("Missing project");
return;
}
// Short-circuit if the project has no variables
if (!project.vars) {
return [];
}
const wasPropsArray = Array.isArray(props);
props = this.normalizeToArray(props);
categories = this.normalizeToArray(categories);
categories = categories.map(function (c) {
return c.toLowerCase();
});
// Add the ID property if we don't have it as we need it to sort
if (!props.includes("id")) {
props.push("id");
}
const v = Convert.extractFromMap(project.vars, props, function (_key, v) {
return (
categories.length === 0 ||
(v.categories &&
v.categories.some(function (vc) {
return categories.includes(vc);
}))
);
}).sort(Sort.Asc("id"));
return wasPropsArray ? v : Convert.extractFromArray(v, props[0]);
};
/**
* Cleans up the current view
*/
this.clean = function () {
// Remove old event handlers
$(window).off("resize scroll click input filterChanged exportFilterChanged widgetChanged");
// Clean up disposable page widgets
for (let name in this.widgets.disposable) {
const wgt = this.widgets.disposable[name];
if (wgt.control) {
// Call any given destructor
if (wgt.control.options && typeof wgt.control.options.destroy === "function") {
wgt.control.options.destroy();
}
// Call the underlying control's destroy method if there is one)
if (typeof wgt.destroy === "function") {
wgt.control.destroy();
}
wgt.control = null;
}
}
this.widgets.disposable = {};
// Remove all filter hooks
this.filters.hooks = [];
};
/**
* Returns the maximum absolute value of all the given array values, ignoring any sign.
*
* @param {array} a
*
* @return {Number}
*/
this.getAbsMax = function (a) {
return a.reduce(function (max, val) {
val = Math.abs(val);
if (val > max) {
max = val;
}
return max;
}, Number.NEGATIVE_INFINITY);
};
/**
* Returns the quantised value of the given number.
*
* @param {Number} n
* @param {Number} quantum
*
* @return {Number|NaN}
*/
this.quantise = function (n, quantum) {
return Math.ceil(n / quantum) * quantum;
};
/**
* Returns the list of Projects that the User is allowed to see, and that are appropriate to this particular portal.
* It also fetches all the Project variables.
*
* @param {array} projects - an array of the user's Projects
*
* @return {array} Projects that are relevant to the current portal and user
*/
this.getApplicableProjects = function (projects) {
if (!Array.isArray(projects)) {
return [];
}
return projects.reduce(function (acc, p) {
if (p.client === config.CUSTOMER && config.PROJECTS.includes(p.name)) {
acc.push(p);
}
return acc;
}, []);
};
/**
* Returns true if the current user is an Admin.
*/
this.isAdmin = function () {
if (!this.Globals.user) {
return false;
}
return (
this.Globals.user.roles.filter(function (r) {
return r === "manage_users" || r === "manage_realm";
}).length > 0
);
};
/**
* Loads the given view into the Page.
*
* @param {string} name Name of the view.
* @param {string} [title] Title of the view (defaults to the name if not supplied)
* @param {boolean} [addToHistory] When true (default) add this view to the browser's history
*/
this.switchToView = function (name, title, addToHistory) {
// Make sure that the page filters ready events are fired, even if there
// are no page filters that drive dataBound events.
checkAllFiltersLoaded("__switchToView");
if (!this.allowedToSee(name)) {
window.alert("No such report");
return;
}
addToHistory = addToHistory !== false;
this.clean();
$.get({
url: "/views/" + name + ".html",
xhrFields: {
withCredentials: true,
},
success: function (html) {
const docTitle = (title || name) + " - " + config.PORTAL_NAME;
$(document).attr("title", docTitle);
$("#widgets").attr("class", "view-" + name);
const view = document.getElementById("widgets");
// Inject the views' HTML
view.innerHTML = html;
// Inject all the widget scripts
const widgets = view.querySelectorAll("*[data-widget]");
for (let c = 0; c < widgets.length; c++) {
const data = widgets[c].dataset;
self.injectScript(data.widget, "/js/widgets/" + data.widget + ".js");
}
// Inject all the Panels
const panels = view.querySelectorAll("*[data-panel]");
for (let c = 0; c < panels.length; c++) {
const panel = new Panel();
panel.attach(panels[c]);
}
// Add to browser history so we can navigate between views
if (addToHistory) {
window.history.pushState({
view: name,
},
"",
"?view=" + name
);
}
// Scroll view contents immediately back to top left
window.scrollTo({
left: 0,
top: 0,
behavior: "auto",
});
$(window).triggerHandler("viewChanged", name);
},
}).fail(function (e) {
if (e.status === 401) {
window.location.replace(window.location.origin + "?view=" + name);
return;
}
console.error("Cannot load the " + name + " view");
});
};
/**
* Returns a Kendo change handler that snaps a date picker's value to the start or end of whichever period its
* depth selects - a month at depth "year", a year at depth "decade" or coarser. Returns null for day-granularity
* pickers (depth "month", Kendo's default) as those select a specific date that must be left alone.
*
* This keeps the "to" end of a date range inclusive of the whole period the user picked, so that a filter ending
* "23:59:59" covers all of it rather than just its first day. It also works around a bug in the version of Kendo
* we're using whereby a datepicker in these modes returns a date whose day-of-month value is today's date if it's
* *ever* been used to pick a date in the current year.
*
* @param {string} [depth] Kendo depth of the picker the handler is for
* @param {string} rangeEnd Which end of the range the picker represents - either 'start' or 'end'
* @param {date} [max] Picker's maximum permitted value, if any
*
* @returns {function|null} Change handler, or null if the picker's values need no snapping
*/
function _getPeriodSnap(depth, rangeEnd, max) {
// A day-granularity picker selects a real date, so there's no period to snap it to
if (!depth || depth === "month") {
return null;
}
const isEnd = rangeEnd === "end";
return function () {
const value = this.value();
if (!value) {
return;
}
let snapped;
if (depth === "year") {
snapped = isEnd ? DateTime.getLastOfMonth(value) : DateTime.getFirstOfMonth(value);
} else {
snapped = isEnd ? DateTime.getLastOfYear(value) : DateTime.getFirstOfYear(value);
}
// Kendo rejects values outside the picker's permitted range, so never snap past its max
if (max && snapped > max) {
snapped = max;
}
this.value(snapped);
};
}
/**
* Creates a new date range widget.
* Expects two DOM input elements with an id that matches the given name plus '-from' and '-to' to use as the base for the widget.
*
* Pickers that select a whole period rather than a specific date (i.e. any depth coarser than "month") have their
* values snapped to the start of that period for the 'from' picker and to the end of it for the 'to' picker, so
* that the generated BETWEEN filter covers the whole of the selected 'to' period. Any change handler passed in
* <code>options</code> runs first and cannot leave a half-open range behind - @see {@link _getPeriodSnap}.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} varName Name of the survey variable associated with this widget.
* @param {object} [options] Custom widget options
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {function} [onChange] Method to call when the widget value changes
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
*
* @see {@link addDatePicker} for options.
*
* @returns {object}
*/
this.addDateRange = function (name, varName, options, isPageFilter, onChange, suppressFilterEvents) {
const widgetType = isPageFilter ? "static" : "disposable";
const control = {
from: (this.widgets[widgetType][name + "-from"] = this.addDatePicker(
name + "-from",
null,
$.extend({}, options, { _rangeEnd: "start" }),
isPageFilter,
onChange,
suppressFilterEvents
)),
to: (this.widgets[widgetType][name + "-to"] = this.addDatePicker(
name + "-to",
null,
$.extend({}, options, { _rangeEnd: "end" }),
isPageFilter,
onChange,
suppressFilterEvents
)),
};
// This doesn't depend on loading any remote data so if it's a filter it's always ready
if (isPageFilter) {
this.filters.ready[name] = true;
}
return (this.widgets[widgetType][name] = {
control: control,
filter: "{" + varName + '} BETWEEN "#value|from# 00:00:00" AND "#value|to# 23:59:59"',
formatter: function (raw) {
return DateTime.getSQLDate(raw);
},
});
};
/**
* Creates a new date picker widget.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} varName Name of the survey variable associated with this widget.
* @param {object} [options] Custom widget options
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {function} [onChange] Method to call when the widget value changes
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/datepicker} for options.
*
* @returns {object}
*/
this.addDatePicker = function (name, varName, options, isPageFilter, onChange, suppressFilterEvents) {
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
const opts = {
format: config.DATE_FORMAT,
month: {
empty: '<span class="k-state-disabled">#= data.value #</span>',
},
};
if (!options || !options.allowFutureDates) {
const max = new Date();
max.setHours(23, 59, 59, 999);
opts.max = max;
}
$.extend(opts, options);
// Internal marker added by addDateRange to say which end of a range this picker is - it's not a Kendo option
const rangeEnd = opts._rangeEnd;
delete opts._rangeEnd;
const control = base.kendoDatePicker(opts).data("kendoDatePicker");
// This doesn't depend on loading any remote data so if it's a filter it's always ready
if (isPageFilter) {
this.filters.ready[name] = true;
}
// Snap range pickers to the period their depth selects. Kendo binds any caller-supplied change handler while
// constructing the widget above, and handlers fire in the order they were bound, so this deliberately sits
// between that handler and the filter trigger below - it corrects the value a caller's own workaround may have
// left behind, and does so before anything reads the value to build a filter.
if (rangeEnd) {
const snap = _getPeriodSnap(opts.depth, rangeEnd, opts.max);
if (snap) {
control.bind("change", snap);
}
}
control.bind("change", function (e) {
if (typeof onChange === "function") {
onChange.call(self, e.sender);
}
if (!suppressFilterEvents) {
self._triggerFilterEvents(name);
}
});
// Add an SQL filter if this control has an associated survey variable - month granularity only
const filter = varName ? function () {
const value = this.control.value();
return value === null ? null : `DATE_FORMAT({${varName}}, \"%Y-%m\") = "${DateTime.getSQLMonth(value)}"`;
} : null;
return (this.widgets[widgetType][name] = {
control: control,
filter,
});
};
/**
* Creates a new data container.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {options} options Custom widget options - NOTE: Only options.filterChanged is supported
*
* @returns {object}
*/
this.addDataValue = function (name, options) {
if (typeof options.filterChanged === "function") {
const el = document.getElementById(name);
const _server = new Proxy(server, getProxyHandler(el));
self.onFilterChanged(options.filterChanged.bind(self, _server));
}
return (self.widgets.disposable[name] = {
control: $("#" + name),
});
};
/**
* Creates a new chart widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/dataviz/ui/chart} for options
*
* @returns {object}
*/
this.addChart = function (name, options) {
// There's a bug in Kendo whereby sometimes a stacked 100% barchart doesn't have a 0-1 range
// so force it here
if (options && options.seriesDefaults && options.seriesDefaults.stack && options.seriesDefaults.type === 'bar' && options.seriesDefaults.stack.type === '100%') {
if (!options.seriesDefaults.valueAxis) options.seriesDefaults.valueAxis = {};
options.seriesDefaults.valueAxis.min = 0;
options.seriesDefaults.valueAxis.max = 1;
}
const opts = $.extend({
autoBind: false,
resizable: true,
chartArea: {
background: "transparent",
},
},
options
);
return createWidget(name, "kendoChart", opts);
};
/**
* Creates a new comparison widget that allows the user to compare survey variables side-by-side by creating up to 6 "personas".
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | project | object | none | The Project used in the comparison. |
* | vars | array | none | Array of Survey Variables to show in the comparison. |
* | filter | string | none | An optional base filter that is **always** applied to **all** personas. |
* | personaLabel | array | none | The root name used for each persona. Each person is numbered so e.g. if personaLabel = "tenant" then the first persona will be "tenant 1". |
* | personaAttributes | array | none | An array describing the attributes that may be set for each persona. |
*
* An example:
*
* const project = client.Globals.projects.find((project) => project.name === 'My Project');
* const datePresets = {
* 'YearEnd 2024': client.Globals.datePreset.twoYearsAgoFinancialYear,
* 'YearEnd 2025': client.Globals.datePreset.lastFinancialYear,
* 'YearEnd 2026': client.Globals.datePreset.thisFinancialYear,
* }
*
* client.addComparison(
* 'comparison2',
* {
* project:,
* vars: client.getProjectVarsByCategory(project, 'compare', ['name', 'caption']),
* filter: '{tenant_type}="LCRA"',
* personaLabel: 'tenant',
* personaAttributes: [
* { name: project.dateVar, label: 'Dates', type: 'daterange', defaultFromFilter: 'departure', presets: datePresets },
* { name: 'tenure_type', label: 'Tenure', type: 'dropdown' },
* { name: 'age_group', label: 'Age Group', type: 'dropdown' },
* { name: 'region', label: 'Region', type: 'dropdown' },
* { name: 'had_repair', label: 'Had a Repair', type: 'dropdown' },
* ]
* })
*
* In personaAttributes:
* "name" is the name of the variable.
* "label" is the label to use for the persona.
* "type" is the type of control to show for the persona. It can be one of:
* - dropdown
* - daterange
* "defaultFromFilter" is optional, and if present will cause the persona's attribute to be initialized from the current page filter for that variable (if any).
* "presets" is optional, and only works for dateranges. It defines a set of date presets that can be used to set the persona's date range.
*
* @returns {object}
*/
this.addComparison = function (name, options) {
const opts = $.extend({
autoBind: false,
resizable: true,
chartArea: {
background: "transparent",
},
},
options
);
return createWidget(name, "kendoTLFComparison", opts);
};
/**
* Creates a new interactive chart with a UI that allows the user to change the chart.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/dataviz/ui/chart} for options
*
* @returns {object}
*/
this.addInteractiveChart = function (name, options) {
const opts = $.extend({
autoBind: false,
resizable: true,
chartArea: {
background: "transparent",
},
},
options
);
return createWidget(name, "kendoTLFInteractiveChart", opts);
};
/**
* Creates a new TabStrip widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/tabstrip} for options.
*
* @returns {object}
*/
this.addTabStrip = function (name, options) {
const opts = $.extend({
animation: {
open: {
effects: "fadeIn",
},
},
},
options
);
return createWidget(name, "kendoTabStrip", opts);
};
/**
* Creates a new Interview widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options -
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
* | datevar | string | null | Name of the survey variable containing an interview date. |
* | export | boolean | false | When true an 'Export as a PDF' button will be shown. |
* | inline | boolean | false | When true the widget will be displayed inline as part of the page. When false it will be hosted inside a Kendo dialog. |
* | journey | object | null | JSON object whose keys define the label text shown in the widget, and whose values are strings that define participant variables (displayed in the left-hand column of the widget in a Participant section) - e.g. { 'My Name': 'participant_name' } |
* | participant | object | null | JSON object whose keys define the label text shown in the widget, and whose values are objects that define customer journey variables (displayed in the left-hand column of the widget in a Journey section), Boolean variables should be identified via a 'bool': true value - e.g. 'One call' : { name: 'manytimes_bool', bool: true} |
* | project | Project | null | Project associated with this widget. |
* | satindex | string | 'satindex' | Name of the survey variable containing a sat index score. |
* | show | function | null | Function that is called when the widget is shown. It is passed the ID of the displayed response. |
* | showZeroScore | boolean | true | When true the Likert scale ranges for sat scores are set to 0-10. When false it's 1-10. |
* | title | string | 'Interview' | Text to be displayed in the widget's tite bar. |
*
* @returns {object}
*/
this.addInterview = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFInterview", opts);
};
/**
* Creates a new Survey Inspector widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options -
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
*
* @returns {object}
*/
this.addSurveyInspector = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFSurveyInspector", opts);
};
/**
* Creates a new Heatmap widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options -
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | project | Project | none | Project to inspect (Mandatory). |
* | aggr | string | none | API aggregator function to use (Mandatory). |
* | aggrBase | string | none | Aggregator base expression (only required for the 'prop' aggregator). |
* | vars | Variable[] | none | Array of Variables to display as grid rows - must have at least the 'id', 'name' and 'label' keys (Mandatory). |
* | group | string | Project dateVar | Grouping variable used to generate columns (Mandatory). |
* | filters | string | Current page filters | Filter to apply to the data. |
* | caption | string | none | The first column header's title - defaults to 'Question'. |
* | scaleMode | string | none | Determines the scope of the Min/Max/Percentile values in the 'scale' key - one of GRID | ROW | COL (Mandatory). GRID = entire grid, ROW = per row, COL = per column |
* | scale | object[] | none | Array of color stops (Mandatory). Must contain either 2 or 3 members. Each stop is of the form { type: <One of 'Min', 'Max', 'Number' or 'Percentile'>, value: Number (only required if type is 'Number' or 'Percentile'), color: Custom color (optional) as '#RRGGBB' }. |
* | format | object[] | 'p1' | Kendo formatting string to apply to each cell's value |
* | invertScale | string[] | none | Optional names of variables to invert the color scale for. |
* | rowHeaderFormatter | string[] | none | Optional function to format each row's header HTML - it is passed the relevant Variable object. |
*
* @example
*
* const project = client.Globals.projects[0];
* const vars = client.getProjectVarsByCategory(project, "var-category", ['id', 'name', 'label']);
*
* // Classify each question by section
* const sections = {
* 'hostel': ['hadprob2', 'hadprob3', 'hadprob5', 'hadprob6', 'hadprob10', 'hadprob12'],
* 'guests': ['hadprob8'],
* 'service': ['hadprob1', 'hadprob4', 'hadprob7', 'hadprob9', 'hadprob11'],
* 'other': ['hadprob13'],
* }
*
* // Custom formatter for each row that prepends the section name to the variable label
* const rowHeaderFormatter = v => {
* // Find which section header the given variable name belongs to
* for (const section in sections) {
* if (sections[section].includes(v.name)) {
* // Prepend the section name to the returned header
* return `<span class="section-header section-header-${section}">${section}</span><span>${v.label}</span>`;
* }
* }
*
* // If no section header was found, just return the variable label
* return v.label;
* };
*
* client.addHeatmap('heatmap-id', {
* project,
* vars,
* aggr: 'prop',
* aggrBase: '{hadprob}=1',
* caption: 'Had a problem',
* scaleMode: 'GRID',
* scale: [
* {
* type: 'Min',
* },
* {
* type: 'Percentile',
* value: 50,
* },
* {
* type: 'Max',
* }
* ],
* invertScale: ['hadprob10'],
* rowHeaderFormatter
* });
*
* In this example the scale consists of three color stops:
*
* First stop: minimum value of the entire grid.
* Second stop: the median value of the entire grid.
* Third stop: the maximum value of the entire grid.
*
* If the scaleMode was set to 'ROW' then the minimum, median and maximum values would be individually calculated for each *row*.
* If the scaleMode was set to 'COL' then the minimum, median and maximum values would be individually calculated for each *column*.
*
* No custom colors were specified so the default colors were used (which are the same as those used by Excel's Conditional Formatting feature).
* The 'hadprob1' question has a negative sense e.g. 'Something was wrong' so its color scale is inverted (i.e. low is green => high is red).
*
* @returns {object}
*/
this.addHeatmap = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFHeatmap", opts);
};
/**
* Creates a new Research Assistant widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options -
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | project | Project | none | Project to inspect (Mandatory). |
*
* @example
*
* const project = client.Globals.projects.find((project) => project.name === 'My Project');
*
* client.addAssistant('my-assistant-container', {project});
*
* @returns {object}
*/
this.addAssistant = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFAssistant", opts);
};
/**
* Adds a Perfect Experience widget.
* Expects a DOM element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Options to control the appearance and behaviour of the widget.
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | addSummary | boolean | true | When true a summary row will be added at the bottom of the widget to show the clients who said 'Yes' to all questions. |
* | animate | boolean | true | When true charts animate when they are first created. |
* | barHeight | number | 30 | The height of each bar in pixels. |
* | categoryColor | string | "grey" | The color of the category label. |
* | categoryPos | string | "outside-top" | The position of the category label relative to the bar - one of 'outside-top', 'outside-bottom', 'outside-end', 'inside-start', 'inside-end' |
* | customFilter | function | null | Defines a function that may be used to modify the filters used to fetch data from the API |
* | detailSplit | string | null | When set to the name of a variable will generate a bar chart split by the variable. The chart is displayed in the expandable detail row. |
* | detailSplitFilter | string | null | Filter used in the API call when generating the split chart. |
* | detailSplitSort | string | null | Sort used in the API call when generating the split chart. |
* | detailTrend | boolean | `false` | When set to `true` the widget displays a trend chart in the expandable detail row. |
* | header | boolean | `true` | When set to `true` the widget displays a header row above the main bar charts. |
* | headerScoreTitle | string | "Sat Score" | Text to show in the header for the Sat scores columns (when `header` is true). |
* | includeDontKnow | boolean | `false` | When true includes 'Don't Know' answers for Yes/No questions |
* | leftBarColor | string | "grey" | Color to use for the left-hand set of bars. |
* | ovsatAggr | string | 'avg' | API aggregator function to be used to calculate the sat result - one of 'avg or 'nps' |
* | ovsatVar | string | OVSAT_VAR_NAME config setting | Name of the variable to be used to calculate the sat result |
* | project | project | null | Project to use to display the widget. **MANDATORY** |
* | quarterly | boolean | `false` | When `true` display the data in quarters (rather than months). |
* | quarterStartMonth | number | 0 (=January) | If the quarters should not start in January specifiy the (zero-based) month number here. |
* | rightBarColor | string | "silver" | Color to use for the right-hand set of bars. |
* | rolling | string | null | The rolling period to apply to the data e.g. `3m(some_var_name)`. |
* | rollingMode | number | `PE_ROLLING_NONE` | Which widget elements to apply the rolling period to. One or more of the following constants: `PE_ROLLING_NONE`, `PE_ROLLING_BAR`, `PE_ROLLING_SAT`, `PE_ROLLING_TREND`, `PE_ROLLING_SPLIT`, `PE_ROLLING_ALL`. Combine mutiple options using bitwise OR e.g. `PE_ROLLING_BAR | PE_ROLLING_SAT` |
* | satFormat | string | "p1" | Kendo format to apply for the displayed Sat scores (applicable when `satScores` is true) - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
* | satScores | boolean | `true` | When true show the numeric sat scores at the ends of each bar |
* | swapBarColors | array | [] | An array of one or more question names for which the left and right colors will be swapped. |
* | vars | array | null | List of Yes/No response var names to display. Each variable will generate a new row with its sat scores and a bar chart. **MANDATORY** |
* | weighted | boolean | `false` | When `true` all displayed data will be weighted using the weighting column identified by the variable in the Project's' `weight` category. |
* | dontWeight | object | null | When the `filters` key is present as an array of var names weights will not be applied when the widget is filtered by those variables e.g. `{ filters: ['foo','bar'] }` |
*
* @returns {object} Perfect Experience instance.
*/
this.addPerfectExperience = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFPerfectExperience", opts);
};
/**
* Creates a new doughnut widget (based on a Kendo chart).
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://demos.telerik.com/kendo-ui/donut-charts/index} for more.
*
* @returns {object}
*/
this.addDoughnut = function (name, options) {
const opts = $.extend({
autoBind: false,
resizable: true,
chartArea: {
background: "transparent",
},
legend: {
visible: false,
},
legendItemClick: function (e) {
//prevent toggling the series visibility on legend item click
e.preventDefault();
},
tooltip: {
visible: true,
},
seriesDefaults: {
type: "donut",
startAngle: 90,
overlay: {
gradient: "none",
},
},
series: [{
name: options.caption || "",
type: "donut",
holeSize: options.holeSize || parseInt(options.chartArea.width / 4.5, 10),
labels: {
color: options.labelColor || "silver",
font: options.labelFont || "sans-serif",
format: options.labelFormat || "n",
},
visual: function (e) {
e.sender._center = e.center;
e.sender._radius = e.radius;
return e.createVisual();
},
data: [{
category: options.categories && options.categories.length > 0 ?
options.categories[0] : "Value",
color: options.valueSegmentColor || "#808080",
},
{
category: options.categories && options.categories.length > 1 ? options.categories[1] : "",
color: options.emptySegmentColor || "#eeeeee",
},
],
},],
render: client.doughnutRenderer,
},
options
);
return createWidget(name, "kendoChart", opts);
};
/**
* Creates a new Pivot Analyser widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @returns {object}
*/
this.addPivotAnalyser = function (name, options) {
const opts = $.extend({
autoBind: true,
},
options
);
return createWidget(name, "kendoTLFPivotAnalyser", opts);
};
/**
* Creates a new Sphere widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
* | baseColor | string | '#808080' | CSS Color to use for the sphere. |
* | speed | number | 1500 | Animation speed. |
* | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
*
* @returns {object}
*/
this.addSphere = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFSphere", opts);
};
/**
* Creates a new Rings widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | animate | string | 'ease-out 2s' | CSS Animation string. |
* | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
* | closed | boolean | true | When true the rings on the widget are closed i.e. are complete circles. |
* | field | string | null | Name of the variable to display in the widget. |
* | labelField | string | null | Name of the variable whose value is displayed at the top of the widget. |
* | transform | function | null | Method that is called with the value of the widget. Must return the value to be displayed. |
* | width | string | '2' | Width of the rings. |
*
* @returns {object}
*/
this.addRings = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFRings", opts);
};
/**
* Creates a new House widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
* | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
* | speed | number | 1000 | Animation speed. |
*
* @returns {object}
*/
this.addHouse = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFHouse", opts);
};
/**
* Creates a new Animated Number widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
* | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
* | speed | number | 1000 | Animation speed. |
*
* @returns {object}
*/
this.addAnimatedNumber = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFAnimatedNumber", opts);
};
/**
* Creates a new View Filters widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | mode | string | null | Sets a 'mode' string that determines how the filters are displayed. |
* | project | Project | null | Sets the Project associated with the filters. |
* | showNotApplicable | boolean | false | When true *all* possible project filters are shown by default. |
*
* @returns {object}
*/
this.addViewFilters = function (name, options) {
const opts = $.extend({
},
options
);
return createWidget(name, "kendoTLFViewFilters", opts);
};
/**
* Creates a new waffle chart widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | autoBind | boolean | false | When true the widget will bind to a data source during initialization. |
* | categories | array | 0 | Array of data category names that are used to divide up the waffle squares. |
* | columns | number | 0 | Number of columns to show in the waffle. |
* | height | number | 200 | Height of the waffle in pixels. |
* | layout | string | 'alternate' | How the waffle squares are filled-in:'left-to-right' or 'alternate' i.e like a Snakes and Ladders board |
* | speed | number | 0 | Animation speed. |
* | series | array | [] | Array of objects representing the data series. |
* | valueFormat | string | 'p0' | Kendo format to use for the displayed values - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
*
* @returns {object}
*/
this.addWaffle = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFWaffle", opts);
};
/**
* Creates a new logged-in User widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} [options] Custom widget options
*
* @returns {object}
*/
this.addUser = function (name, options) {
options = options || {};
const opts = $.extend({
autoBind: false,
},
options
);
const widget = createWidget(name, "kendoTLFUser", opts);
return widget;
};
/**
* Creates a new stage widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @returns {object}
*/
this.addStage = function (name, options) {
const opts = $.extend({
autoBind: false,
resizable: true,
},
options
);
return createWidget(name, "kendoTLFStage", opts);
};
/**
* Creates a new podium widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @returns {object}
*/
this.addPodium = function (name, options) {
const opts = $.extend({
autoBind: false,
resizable: true,
},
options
);
return createWidget(name, "kendoTLFPodium", opts);
};
/**
* Creates a new journey widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @returns {object}
*/
this.addJourney = function (name, options) {
const opts = $.extend({
autoBind: false,
resizable: true,
},
options
);
return createWidget(name, "kendoTLFJourney", opts);
};
/**
* Creates a new sparkline widget i.e a simple chart with no axis, legend or tooltips.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/controls/charts/sparkline/overview} for more.
*
* @returns {object}
*/
this.addSparkline = function (name, options) {
const opts = $.extend({
autoBind: false,
chartArea: {
background: "transparent",
},
},
options
);
return createWidget(name, "kendoSparkline", opts);
};
/**
* Creates a new grid widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/grid} for options.
*
* @returns {object}
*/
this.addGrid = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoGrid", opts);
};
/**
* Creates a new Hot Alerts widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | author | string | "Unknown" | The name of the author that will be used in the attribution when comments and status changes are made. |
* | filters | function | `client.getPageFilters()` | Function that must return an array of filters to be used when requesting HAs. The default simply uses any global page filters. |
* | project | object | None | The Project to be used by the widget. |
* | showFilter | boolean | `false` | When set to `true` the widget displays a multi-select that can be used to filter the HA grid by one or more statuses. |
* | showStats | boolean | `false` | When set to `true` the widget displays counts for each possible HA status. |
* | statuses | array | ['Open', 'Pending', 'Resolved'] | HA statuses to be shown. |
*
* @returns {object}
*/
this.addHotAlerts = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFHotAlerts", opts);
};
/**
* Creates a new Taskboard widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
*
* @returns {object}
*/
this.addTaskboard = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFTaskboard", opts);
};
/**
* Creates a new Categories widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* Options specific to this widget:
* project {object} The project to fetch category data from
* codingVar {string} The coding variable name (default: 'coding')
* dateVar {string} The date variable name (defaults to project.dateVar)
*
* The server fixes the columns it returns, so codingVar and dateVar only name the keys to read
* from each row - they do not change what is fetched.
*
* Bar length and the right-hand figure are the percentage of coded respondents who mention the
* category. A respondent counts once however many comment questions they answered, and
* respondents are multi-coded, so these do not sum to 100%. The sentiment colours within a bar
* are shares of that category's mentions, which is a different denominator - a respondent
* tagged both positively and negatively counts once in the bar length but twice there.
*
* @returns {object}
*/
this.addCategories = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoTLFCategories", opts);
};
/**
* Creates a new slider widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/slider} for options.
*
* @returns {object}
*/
this.addSlider = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoSlider", opts);
};
/**
* Creates a new range slider widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/rangeslider} for options.
*
* @returns {object}
*/
this.addRangeSlider = function (name, options) {
const opts = $.extend({
autoBind: false,
},
options
);
return createWidget(name, "kendoRangeSlider", opts);
};
/**
* Creates a new TreeList widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Kendo TreeView widget options, plus the following TLF extensions:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | persistExpandedNodes | bool | false | When true the control will remember the expanded state of nodes. |
* | persistScrollPosition | bool | false | When true the control will remember the current scroll position. |
* | filter | function | undefined | A function that will be called with the rows returned by each API request. It may modify them before returning them. |
* | databound | function | undefined | A function that will be called when the treelist's data is bound. It is passed the data array and may modify it before returning it. |
*
* Each element in the Kendo columns array may specify any of the Kendo Column options, plus the following TLF extensions:
*
* | Key | Type | Default | Comment |
* | --- | --- | --- | --- |
* | hierarchy | array | undefined | Defines the hierarchy to be diplayed in the column. In this case the field option must be set to 'value'. |
* | api | object | undefined | Defines the API call to be used to fetch the column's data e.g. { aggregator: "avg", variable: "ease" } |
*
* An example hierarchy object (expanded = true means the node is expanded to show its children on first load) would be:
*
* hierarchy: [
* { text: 'Overall', expanded: true },
* { varName: 'area', expanded: true },
* { varName: 'region' },
* { varName: 'company' },
* { varName: 'project_manager' },
* { varName: 'respondent' }
* ]
*
* @param {function} filter A filter function that is called when the treelist's data is bound. It is passed the rows array and may modify it before returning it.
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/treelist} for TreeList options.
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/treelist/configuration/columns} for TreeList column options.
*
* @returns {object}
*/
this.addTreeList = function (name, options) {
// Method to calculate a unique hash for an object
const calculateHash = obj => JSON.stringify(obj, Object.keys(obj).sort());
// Install a default handler for the filterChanged event if no handler has been provided
if (!options.filterChanged) {
options.filterChanged = _updateTreeList;
}
const tlw = createWidget(name, "kendoTreeList", options);
const tl = tlw.control;
if (tl.options.persistExpandedNodes) {
tl.bind("expand", _saveExpandedState);
tl.bind("collapse", _saveExpandedState);
tl.bind("dataBound", _restoreExpandedState);
}
if (tl.options.persistScrollPosition) {
const gridContent = $("#" + name + " .k-grid-content");
gridContent.scroll(function () {
tlw.scrollTop = this.scrollTop;
});
tl.bind("dataBound", function () {
if (typeof tlw.scrollTop !== "undefined") {
gridContent[0].scrollTop = tlw.scrollTop;
}
});
}
return tlw;
function _saveExpandedState() {
// Have to do this after a short delay because the event fires *before* the node changes state
setTimeout(function () {
tlw.expandedNodes = JSON.stringify(
$.map($("#" + name + " .k-i-collapse").closest("tr"), function (val) {
return $(val).index();
})
);
}, 200);
}
function _restoreExpandedState() {
if (tlw.expandedNodes) {
$.each(JSON.parse(tlw.expandedNodes), function (idx, val) {
tl.expand(tl.content.find("tr").eq(val));
});
}
}
function _updateTreeList() {
if (!options.columns) {
console.error('The default handler for a TreeGrid widget requires a columns specification');
return;
}
const grid = client.getControl(name);
if (grid) {
// This may take a while...
kendo.ui.progress(grid.element, true);
const proj = client.Globals.projects[0];
const group = Convert.extractFromArray(options.columns[0].hierarchy, 'varName', h => h.varName);
const groupVars = group.map(g => `{${g}}`);
const hierarchyGroup = [...group];
const signatureVars = hierarchyGroup.reduce((arr, g) => {
arr.push(g);
arr.push(`_aggr_${g}`);
return arr;
}, []);
const hierarchyVar = hierarchyGroup.pop();
const colsWithAPICalls = options.columns.filter(c => c.api);
const pageFilters = client.getPageFiltersAsString();
// Always load data for the hierarchy column, and add any API queries from the columns config
const queries = [
server.getResponsesDistinct(proj, hierarchyVar, pageFilters, hierarchyGroup.map(g => `{${g}}`)),
...colsWithAPICalls.map((col, colIdx) => server.getResponsesAggregate('COL' + (colIdx + 2), client.Globals.projects[0], col.api.aggregator, col.api.variable, pageFilters, groupVars, null, true))
];
// The done method runs when all the above async remote requests complete, and receives the result as arguments in query order
$.when.apply($, queries).done(function () {
// Normalize results
const results = arguments[1] === "success" ? [arguments] : [...arguments];
// The hierachy is derived from the first result
const firstResult = options.filter ? options.filter(results[0][0].rows) : results[0][0].rows;
const { hierarchy, hierarchyIndex } = _getGridHierarchy(firstResult);
// First column of the grid data contians the hierarchy
let data = JSON.parse(JSON.stringify(hierarchy));
// Add remote data to the local data object.
// Each argument contains the results of the queries defined above.
for (let c = 1; c < arguments.length; c++) {
const results = arguments[c][0];
if (results.rows.length) {
const result = options.filter ? options.filter(results.rows) : results.rows;
const varName = colsWithAPICalls[c - 1].api.variable;
const dataValue = result[0][varName];
if (typeof dataValue !== 'undefined' && dataValue !== null) {
result.forEach(function (varData) {
if (varData[varName]) {
const hIndex = _getHierarchyIndex(varName, signatureVars, hierarchyIndex, varData);
if (hIndex != -1) {
const col = colsWithAPICalls[results.tag.slice(3) - 2];
data[hIndex][col.field] = kendo.toString(varData[varName], col.format || 'n0');
} else {
console.warn('Cannot find entry for', varData, 'is the data correct?');
}
}
});
}
}
}
// Call any method to modify data before display
if (typeof options.update === 'function') data = options.update(data);
client.setWidgetDataSource(name, data);
// Remove spinner
kendo.ui.progress(grid.element, false);
});
}
}
// Returns a hierarchy in the flattened format required by the Kendo widget
function _getGridHierarchy(rawHierarchy) {
const rootNode = options.columns[0].hierarchy[0];
let id = 1;
// Convert all the rows into an JS object hierarchy
const hierarchy = {};
// Get all the keys for each level of the hierarchy
const hierarchyVars = Convert.extractFromArray(options.columns[0].hierarchy, 'varName', h => h.varName);
rawHierarchy.forEach((r, rowIdx) => {
// Warn about any rows with nulls (and skip it)
for (const key in r) {
if (r[key] === null) {
console.warn(`client.Globals.hierarchy row ${rowIdx} has one or more null values - skipping`);
return;
}
}
// Add data for this row to the relevant part of the object hierarchy
let thisLevel = hierarchy;
// Add all bar the last level to the hierarchy
for (let idx = 0; idx < hierarchyVars.length - 1; idx++) {
const k = hierarchyVars[idx];
// If this is the last level make its initial value an array not an object (so we can push the last level values on to it)
if (typeof thisLevel[r[k]] === 'undefined') { thisLevel[r[k]] = idx === hierarchyVars.length - 2 ? [] : {} };
// Move down one level
thisLevel = thisLevel[r[k]];
}
// Push this row's last-level value into the last level's array
thisLevel.push(r[hierarchyVars[hierarchyVars.length - 1]]);
})
// Create a signature so as to identify the data requirements for each row
// We start with needing all the _aggr_ values to be 1 (i.e. the overall summary results).
const signature = hierarchyVars.reduce((sig, varName) => {
sig[varName] = null;
sig[`_aggr_${varName}`] = 1;
return sig;
}, {});
// Define the root node of the hierarchy
let treeData = [
{ id: id++, parentId: null, value: rootNode.text, expanded: rootNode.expanded, signature },
];
// Convert the object hierarchy into the format needed by the Kendo TreeGrid and add it to the given TreeData array
_addLevel(treeData, hierarchy, 1, 0, { ...signature });
// Create an index to the hierarchy to improve performance when looking up values based on the signature.
// We add a sort here to ensure that object keys in the stringified result are always in the same order.
const treeIndex = treeData.reduce((obj, d, idx) => {
obj[calculateHash(d.signature)] = idx;
return obj;
}, {});
return { hierarchy: treeData, hierarchyIndex: treeIndex };
// _addLevel recursively adds all the data in the given object hierarchy to the given Kendo TreeGrid array
function _addLevel(data, h, parentId, levelId, signature) {
const levelSig = { ...signature };
const isLeafNode = Array.isArray(h);
const levelVar = hierarchyVars[levelId];
// Adjust the required aggregation settings for this level in the signature
if (levelVar) levelSig[`_aggr_${levelVar}`] = 0;
if (isLeafNode) levelSig[`_aggr_${levelVar}`] = 0;
for (const key in h) {
// If this is a leaf node then get the value of it rather than its key.
// Leaf nodes may have multiple values encoded as a single string delimited by pipes.
const values = isLeafNode ? h[key].split('|') : [key];
values.forEach(value => {
const rowSig = { ...levelSig };
rowSig[levelVar] = value;
data.push({ id: id++, parentId, value, expanded: options.columns[0].hierarchy[levelId + 1].expanded, signature: { ...rowSig } });
// If this datum is an object (this test will also match an Array) we need to drop down a level
if (typeof h[key] === 'object') {
_addLevel(data, h[key], id - 1, levelId + 1, rowSig);
}
});
}
}
}
// Returns the index of the hierarchy row whose signature matches that of the given varData (or -1 if it wasn't found).
function _getHierarchyIndex(varName, signatureVars, index, data) {
// Extract keys for comparison
const compareData = _extractKeys(data, signatureVars);
// Remove the specified varName from the comparison data
delete compareData[varName];
// Calculate the hash of the modified comparison data
const hash = calculateHash(compareData);
// Return the index if the hash exists, otherwise return -1
return index[hash] !== undefined ? index[hash] : -1;
}
// extractKeys extracts only the keys in the given array from the given object
function _extractKeys(obj, keysArray) {
return keysArray.reduce((result, key) => {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = obj[key];
}
return result;
}, {});
}
};
/**
* Creates a new TreeView widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/treeview} for options.
*
* @returns {object}
*/
this.addTreeView = function (name, options) {
const tlw = createWidget(name, "kendoTreeView", options);
const tl = tlw.control;
if (tl.options.persistExpandedNodes) {
tl.bind("expand", saveExpandedState);
tl.bind("collapse", saveExpandedState);
tl.bind("dataBound", restoreExpandedState);
}
if (tl.options.persistScrollPosition) {
const gridContent = $("#" + name + " .k-grid-content");
gridContent.scroll(function () {
tlw.scrollTop = this.scrollTop;
});
tl.bind("dataBound", function () {
if (typeof tlw.scrollTop !== "undefined") {
gridContent[0].scrollTop = tlw.scrollTop;
}
});
}
return tlw;
function saveExpandedState() {
// Have to do this after a short delay because the event fires *before* the node changes state
setTimeout(function () {
tlw.expandedNodes = JSON.stringify(
$.map($("#" + name + " .k-i-collapse").closest("tr"), function (val) {
return $(val).index();
})
);
}, 200);
}
function restoreExpandedState() {
if (tlw.expandedNodes) {
$.each(JSON.parse(tlw.expandedNodes), function (idx, val) {
tl.expand(tl.content.find("tr").eq(val));
});
}
}
};
/**
* Creates a new Calendar widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link http://docs.telerik.com/kendo-ui/api/javascript/ui/calendar|kendo.ui.Calendar} for options.
*
* @returns {object}
*/
this.addCalendar = function (name, options) {
return createWidget(name, "kendoCalendar", options);
};
/**
* Creates a new radial gauge widget.
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/dataviz/ui/radialgauge} for options.
*
* @returns {object}
*/
this.addRadialGauge = function (name, options) {
return createWidget(name, "kendoRadialGauge", options);
};
/**
* Creates a new map widget (centred on the UK by default).
* Expects a DOM div element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Custom widget options
*
* @returns {object}
*/
this.addMap = function (name, options) {
const self = this;
const dataSources = [];
const opts = $.extend({
style: "standard",
center: [55.781, -5.962],
zoom: 5,
minZoom: 5,
maxZoom: 16,
mouseZoom: false,
mapLayers: [],
layers: [],
shapeCreated: onShapeCreated,
shapeFeatureCreated: onShapeFeatureCreated,
shapeMouseEnter: onShapeMouseEnter,
shapeMouseLeave: onShapeMouseLeave,
shapeClick: onShapeClick,
markerCreated: onMarkerCreated,
},
options
);
const shapeStyles = {};
// Add background map tiles
let baseURL;
switch (opts.style) {
case "standard":
baseURL = "https://a.tile.openstreetmap.org";
break;
case "humanitarian":
baseURL = "https://a.tile.openstreetmap.fr/hot";
break;
default:
console.error("Unsupported map style " + opts.style);
return;
}
opts.layers.push({
name: "background",
autoBind: false,
type: "tile",
urlTemplate: baseURL + "/#= zoom #/#= x #/#= y #.png",
attribution: "© OpenStreetMap contributors",
});
// Convert any layers into Kendo-style layers
opts.mapLayers.forEach((layer) => {
if (!layer.name) {
console.error("Missing layer name");
return;
}
if (!layer.dataset) {
console.error("Missing dataset for layer " + layer.name);
return;
}
if (!layer.datasetNameField) {
console.error("Missing datasetNameField for layer " + layer.name);
return;
}
layer.areas = layer.areas || {};
layer.show = layer.show || [];
// Convert a simple color-shortcut scale to the full object format i.e. [ { label: 'xx', min: a, max: b, style: {...} }, {...} ]
if (Array.isArray(layer.scale)) {
for (let i = 0; i < layer.scale.length; i++) {
if (typeof layer.scale[i] === "string") {
layer.scale[i] = {
label: `${i}-${i + 1}`,
min: i,
max: i + 1,
style: {
fill: {
color: layer.scale[i],
},
},
};
}
}
if (opts.showScale) {
addScaleLegend(layer.scale);
}
}
const dataSourceOpts = {
type: "geojson",
transport: {
read: `https://static.leadershipfactor.com/vendor/ons/${layer.dataset}.json`,
},
};
// Filter and transform results
const show = Convert.extractFromArray(layer.show, "name");
dataSourceOpts.schema = {
parse: function (data) {
data.features = data.features
// Apply any aliases
.map((row) => {
if (
layer.datasetTransform &&
layer.datasetTransform[row.properties[layer.datasetNameField]]
) {
row.properties[layer.datasetNameField] =
layer.datasetTransform[row.properties[layer.datasetNameField]];
}
return row;
})
// Filter if we have a show array
.filter(
(row) =>
show.length === 0 || show.indexOf(row.properties[layer.datasetNameField]) !== -1
);
// Process any layer Area groupings
if (layer.type === "shape") {
Object.keys(layer.areas).forEach((area) => {
if (layer.areas[area].group) {
const showLocations = Convert.extractFromArray(
layer.show,
"name",
(lyr) => lyr.area === area
);
// Scan all the geo features, and if they're in the group remember their coordinates
// and omit them from newFeatures (as the new grouped coords will replace them)
const newFeatures = [];
const memberPolygons = data.features.reduce((acc, f) => {
if (
showLocations.length &&
showLocations.indexOf(f.properties[layer.datasetNameField]) !== -1
) {
const coords =
f.geometry.type === "Polygon" ?
turf.polygon(f.geometry.coordinates) :
turf.multiPolygon(f.geometry.coordinates);
acc.push(coords);
} else {
newFeatures.push(f);
}
return acc;
}, []);
data.features = newFeatures;
// Create a union of all the grouped areas
const groupValueGetter = layer.areas[area].getGroupValue;
if (memberPolygons.length) {
const union = turf.union(...memberPolygons);
union.properties[layer.datasetNameField] = `${area}: ${showLocations.join(", ")}`;
union.properties.group = true;
union.properties.getGroupValue = groupValueGetter ?
groupValueGetter.bind(self, showLocations) :
null;
union.properties.area = area;
data.features.push(union);
}
}
});
}
// Convert marker-layer data into a suitable format
if (layer.type === "marker") {
data = data.features.reduce((acc, d) => {
acc.push({
location: d.geometry.coordinates,
title: d.properties.name,
});
return acc;
}, []);
}
return data;
},
};
const dataSource = new kendo.data.DataSource(dataSourceOpts);
dataSources.push(dataSource);
switch (layer.type) {
case "shape":
const shapeLayer = $.extend({
name: layer.name,
type: layer.type,
autoBind: false,
dataSource: dataSource,
}, {
style: layer.defaultAreaStyle || {},
});
opts.layers.push(shapeLayer);
break;
case "marker":
opts.layers.push({
name: layer.name,
type: layer.type,
autoBind: false,
dataSource: dataSource,
});
break;
default:
console.error("Unexpected layer type", layer.type);
}
});
// Emulate a refresh method as the Kendo map doesn't appear to have one :(
opts.refresh = () => {
dataSources.forEach((ds) => {
ds.read();
});
};
// Sets the method used to calculate the value associated with the given layer
opts.addGetLayerShapeValue = (layer, fn) => {
const layers = opts.mapLayers.filter((l) => l.name === layer);
if (layers.length === 0) {
console.warn("No such layer " + layer);
return;
}
layers[0].getValue = fn;
};
// Sets the method used to calculate the value associated with the given grouped Area
opts.addGetLayerGroupedAreaValue = (layer, area, fn) => {
const layers = opts.mapLayers.filter((l) => l.name === layer);
if (layers.length === 0) {
console.warn("No such layer " + layer);
return;
}
const layerArea = layers[0].areas[area];
if (!layerArea) {
console.warn(`No such area ${area} in layer ${layer}`);
return;
}
layerArea.getGroupValue = fn;
};
// Sets the method used to return a style object for the given layer's area
opts.addGetAreaStyle = (layer, area, fn) => {
const layers = opts.mapLayers.filter((l) => l.name === layer);
if (layers.length === 0) {
console.warn("No such layer " + layer);
return;
}
layers[0].areas = layers[0].areas || {};
layers[0].areas[area] = fn;
};
// Method to return the style associated with a given scale value
opts.getScaleStyle = (layer, value) => {
const layers = opts.mapLayers.filter((l) => l.name === layer);
if (layers.length === 0) {
console.warn("No such layer " + layer);
return {};
}
if (value === null) {
return layers[0].defaultAreaStyle;
}
const scale = layers[0].scale || [];
const matches = scale.filter((s) => value >= s.min && value < s.max);
return matches.length ? matches[0].style : {};
};
const widget = createWidget(name, "kendoMap", opts);
// Overlay any map layer scales
opts.mapLayers.forEach((layer) => {
if (layer.showScale) {
if (!layer.scale) {
console.err("Missing scale for layer", layer.name);
} else {
addScaleLegend(layer.showScale.position || "right", layer);
}
}
});
if (opts.mouseZoom === false) {
widget.control.element.unbind("mousewheel");
widget.control.element.unbind("DOMMouseScroll");
}
return widget;
function addScaleLegend(posn, layer) {
const defaultBackground =
typeof layer.defaultAreaStyle.fill.color !== "undefined" ?
layer.defaultAreaStyle.fill.color :
"grey";
const rows = layer.scale.reduce(
(h, s) =>
(h += `<tr><td>${s.label}</td><td style="background:${s.style.fill.color}"></td></tr>`),
""
);
const table = `
<table class="map-scale k-pos-${posn}">
<thead><tr><th colspan="2">Score</th></tr></thead>
<tbody>
<tr><td>n/a</td><td style="background:${defaultBackground}"></td></tr>
${rows}
</tbody>
</table>
`;
widget.control.element[0].insertAdjacentHTML("beforeend", table);
}
function onShapeCreated(e) {
const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
const area = getShapeArea(layerOpts, e.shape);
if (!area) {
return;
}
const feature = e.shape.dataItem.properties[layerOpts.datasetNameField];
let shapeStyle = {};
// Handle a callback to set the shape style
if (typeof layerOpts.areas[area] === "function") {
shapeStyle = layerOpts.areas[area](feature);
}
// Handle a hard-coded shape style object
if (typeof layerOpts.areas[area] === "object") {
shapeStyle = layerOpts.areas[area];
}
$.extend(true, e.shape.options, layerOpts.defaultAreaStyle, shapeStyle);
shapeStyles[feature] = {
...e.shape.options,
};
}
function getShapeArea(layerOpts, shape) {
if (shape.dataItem.properties.group) {
return shape.dataItem.properties.area;
}
const location = shape.dataItem.properties[layerOpts.datasetNameField];
const show = layerOpts.show.filter((s) => s.name === location)[0];
if (show.length === 0) {
console.warn(location + " does not exist in the dataset");
return null;
}
return show.area;
}
function onShapeFeatureCreated(e) {
const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
const feature = e.dataItem.properties[layerOpts.datasetNameField];
const area = Convert.extractFromArray(layerOpts.show, "area", (s) => s.name === feature)[0];
const members = Convert.extractFromArray(layerOpts.show, "name", (s) => s.area === area);
const shapeValue =
typeof layerOpts.getValue === "function" ? layerOpts.getValue(feature) : null;
const data = {
...e.properties,
_NAME: feature,
_AREA: area,
_MEMBERS: members.join(", "),
_VALUE: shapeValue ? shapeValue : "n/a",
};
let template;
if (area) {
// Default area tooltip shows the feature's name and its associated value
template =
layerOpts.areas && layerOpts.areas[area] && layerOpts.areas[area].label ?
layerOpts.areas[area].label :
"#: _NAME # (#: _VALUE #)";
} else {
template = "#: _NAME #";
}
e.group.options.tooltip = {
content: kendo.template(template)(data),
position: "cursor",
offset: 10,
width: 80,
stroke: 1,
};
}
function onShapeMouseEnter(e) {
const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
if (layerOpts.hoverAreaStyle) {
const feature = e.shape.dataItem.properties[layerOpts.datasetNameField];
const style = $.extend(true, {}, shapeStyles[feature], layerOpts.hoverAreaStyle);
setShapeStyle(e.shape, style);
}
}
function onShapeMouseLeave(e) {
const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
if (layerOpts.hoverAreaStyle) {
const feature = e.shape.dataItem.properties[layerOpts.datasetNameField];
const style = $.extend(true, {}, layerOpts.defaultAreaStyle, shapeStyles[feature]);
setShapeStyle(e.shape, style);
}
}
function onShapeClick(e) {
const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
const item = e.shape.dataItem;
if (typeof layerOpts.onClick === "function" && item) {
layerOpts.onClick(item.properties, getShapeValue(layerOpts, item));
}
}
function getShapeValue(layerOpts, item) {
// Grouped geo features need to provide a custom meethod to return their value
if (item.properties.group && typeof item.properties.getGroupValue === "function") {
return item.properties.getGroupValue();
}
const feature = item.properties[layerOpts.datasetNameField];
if (typeof layerOpts.getValue === "function") {
return layerOpts.getValue(feature);
}
return null;
}
function setShapeStyle(shape, style) {
shape.fill(
style && style.fill && style.fill.color ? style.fill.color : "gray",
style && style.fill && style.fill.opacity ? style.fill.opacity : 1
);
}
function onMarkerCreated(e) {
const layerOpts = opts.mapLayers.filter((l) => l.name === e.layer.options.name)[0];
e.marker.options.shape = layerOpts.shape || "pinTarget";
e.marker.options.tooltip = {
offset: -40,
stroke: 1,
};
}
};
/**
* Creates a new dropdown list widget and populates it with the given data.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {array} data The widget's datasource
* @param {string} valueProp Datasource property to use for each list item's form value
* @param {string} [textProp] Datasource property to use for each list item's displayed text
* @param {string} [placeholder] Placeholder value
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {function} [onChange] Method to call when the widget value changes
* @param {string} [popupContainer] CSS selector of the element to append the popup containers to.
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
* @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
* @param {boolean} [multiVar] When true the dropdown is given a list of variables to choose from.
*
* @returns {object}
*/
this.addDropdownFromData = function (
name,
data,
valueProp,
textProp,
placeholder,
isPageFilter,
onChange,
popupContainer,
suppressFilterEvents,
omitFromFilters,
multiVar
) {
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
const clone = data.slice();
if (base.length === 0) {
console.error("Cannot find an HTML element with id ", name);
return;
}
if (placeholder) {
const item = {};
item[valueProp] = multiVar ? "" : 0;
item[textProp] = placeholder;
clone.unshift(item);
}
if (isPageFilter) {
this.filters.ready[name] = false;
}
const control = base
.kendoDropDownList({
dataValueField: valueProp,
dataTextField: textProp || valueProp,
autoWidth: true,
valuePrimitive: true,
dataSource: {
data: clone,
},
dataBound: function () {
checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
},
popup: {
appendTo: $(popupContainer),
},
change: function (e) {
let triggerFilterChanged = true;
localStorage.setItem("widget-" + name, e.sender.value());
if (typeof onChange === "function") {
triggerFilterChanged = onChange.call(self, e.sender) !== false;
}
if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
self._triggerFilterEvents(name);
}
},
})
.data("kendoDropDownList");
// In a multivar dropdown the value of the control is the name of the 'boolean' variable to test
const filter = multiVar ? '#value#=1' : `{${valueProp}}="#value#"`;
return (this.widgets[widgetType][name] = {
control: control,
filter: omitFromFilters ? null : filter,
});
};
/**
* Creates a new Kendo UI dropdown-style widget and populates it with all the distinct values of the given survey variable.
* This is the common factory method for addDropdownFromSurveyVar and addComboBoxFromSurveyVar.
*
* @param {string} widgetName The name of the Kendo widget to create (e.g., 'kendoDropDownList', 'kendoComboBox').
* @param {object} widgetOptions The widget-specific configuration options.
* @param {object} project Project to use
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} varName Name of the survey variable to use
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {function} [onChange] Method to call when the widget value changes
* @param {string} [placeholder] Placeholder text
* @param {string} [groupVar] Name(s) of the variable to group the results by
* @param {string} [popupContainer] CSS selector of the element to append the popup containers to.
* @param {array} [order] Order of the dropdown items.
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
* @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
*
* @returns {object}
*/
this._addKendoWidgetFromSurveyVar = function (
widgetName,
widgetOptions,
project,
name,
varName,
isPageFilter,
onChange,
placeholder,
groupVar,
popupContainer,
order,
suppressFilterEvents,
omitFromFilters,
filter
) {
popupContainer = popupContainer || "body";
const self = this;
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
const projectVar = server.getProjectVar(project, varName);
if (typeof projectVar === "undefined") {
// This might be because we're not allowed to see this variable's category
return;
}
// getProjectVar will fetch a name independent of case so make sure varName is the actual name of the var
varName = projectVar.name;
if (isPageFilter) {
this.filters.ready[name] = false;
}
let fetchVarName = varName;
let sortVarName = varName;
// If we want to group by a variable then we need to ask the API for distinct responses for *that* variable, and then
// group the results by the given survey variable
let group = "";
if (groupVar) {
fetchVarName = groupVar;
group = "&group={" + varName + "}";
}
filter = filter ? "&filter=" + filter : '';
const fetchVar = server.getProjectVar(project, fetchVarName);
const url =
Server.API_ENDPOINT +
"/project/" +
project.id +
"/responses/distinct/" +
fetchVar.id +
"?sort={" +
sortVarName +
"}" +
filter +
group;
const ds = {
transport: {
read: {
dataType: "json",
cache: false,
url: url,
xhrFields: {
withCredentials: true,
},
},
},
change: function (e) {
e.items.forEach(function (item) {
item._value = item[fetchVar.name];
// Replace raw boolean 0/1 text with variable metadata
if (fetchVar.type === "bool") {
if (item._value === 0 && fetchVar.falsevalue) {
item._text = fetchVar.falsevalue;
return;
}
if (item._value === 1 && fetchVar.truevalue) {
item._text = fetchVar.truevalue;
return;
}
}
// Handle a null value
if (item._value === null) {
item._text = "n/a";
item._value = self.NULL;
return;
}
item._text = item._value;
});
},
schema: {
data: "rows",
parse: function (response) {
// If a specific item order is specified then apply it
if (order) {
response.rows = Move.inOrder(response.rows, fetchVarName, order);
}
for (let i = 0; i < response.rows.length; i++) {
const row = response.rows[i];
Object.keys(row).forEach(function (datum) {
if (typeof row[datum] === "string" && row[datum] && !isNaN(row[datum])) {
row[datum] = Number(row[datum]);
}
});
}
return response;
},
},
};
if (groupVar) {
ds.group = {
field: groupVar,
};
}
// Base configuration common to both widgets
const commonConfig = {
dataValueField: "_value",
dataTextField: "_text",
dataSource: ds,
dataBound: function () {
checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
},
popup: {
appendTo: $(popupContainer),
},
change: function (e) {
let triggerFilterChanged = true;
localStorage.setItem("widget-" + name, e.sender.value());
if (typeof onChange === "function") {
triggerFilterChanged = onChange.call(self, e.sender) !== false;
}
if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
self._triggerFilterEvents(name);
}
},
};
// Combine common and widget-specific options
const finalConfig = Object.assign({}, commonConfig, widgetOptions);
// Dynamically call the correct Kendo widget initializer
const control = base[widgetName](finalConfig).data(widgetName);
return (this.widgets[widgetType][name] = {
control: control,
filter: omitFromFilters ? null : "{" + varName + '}="#value#"',
});
};
/**
* Creates a new dropdown list widget and populates it with all the distinct values of the given survey variable.
*/
this.addDropdownFromSurveyVar = function (...args) {
const widgetName = "kendoDropDownList";
// DropDownList uses 'optionLabel' instead of 'placeholder'
const placeholder = args[5];
const widgetOptions = {
optionLabel: placeholder,
};
return this._addKendoWidgetFromSurveyVar(widgetName, widgetOptions, ...args);
};
/**
* Creates a new combobox widget and populates it with all the distinct values of the given survey variable.
*/
this.addComboBoxFromSurveyVar = function (...args) {
const widgetName = "kendoComboBox";
const placeholder = args[5];
const widgetOptions = {
placeholder: placeholder,
filter: "contains",
};
return this._addKendoWidgetFromSurveyVar(widgetName, widgetOptions, ...args);
};
/**
* Creates a new dropdown widget and populates it with all the distinct values from all the survey variables in the given variable category.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* NOTE: Each variable in the category is expected to be a nullable boolean value.
*
* @param {object} project Project to use
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} categoryName Name of the survey variable category to use
* @param {string} [placeholder] Placeholder value
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {function} [onChange] Method to call when the widget value changes
* @param {string} [popupContainer] CSS selector of the element to append the popup containers to.
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
* @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/dropdownlist} for options.
*
* @returns {object}
*/
this.addDropdownFromSurveyVarCategory = function (
project,
name,
categoryName,
placeholder,
isPageFilter,
onChange,
popupContainer,
suppressFilterEvents,
omitFromFilters,
) {
const items = [];
const vars = this.getProjectVarsByCategory(project, categoryName, ["name", "label"]);
// Add all the category variables
items.push(...vars);
return client.addDropdownFromData(
name,
items,
"name",
"label",
placeholder,
isPageFilter,
onChange,
popupContainer,
suppressFilterEvents,
omitFromFilters,
true
);
};
/**
* Creates a new dropdown list widget and populates it with a ist of ranges based on the given divisions.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* @param {object} project Project to use
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} varName Name of the survey variable to apply the given ranges to
* @param {array} divisions Divisions that separate each range
* @param {bool} extend If true the generated ranges include 'catch-all' ranges before and after the specified divisions
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {function} [onChange] Method to call when the widget value changes
* @param {string} [placeholder] Placeholder text
* @param {string} [popupContainer] CSS selector of the element to append the (hidden) popup containers to.
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
* @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
*
* @returns {object}
*/
this.addDropdownFromDivisions = function (
project,
name,
varName,
divisions,
extend,
isPageFilter,
onChange,
placeholder,
popupContainer,
suppressFilterEvents,
omitFromFilters
) {
popupContainer = popupContainer || "body";
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
if (server.getProjectVar(project, varName) === "undefined") {
// This might be because we're not allowed to see this variable's category
return;
}
// This doesn't depend on loading any remote data so if it's a filter it's always ready
if (isPageFilter) {
this.filters.ready[name] = true;
}
if (divisions.length < (extend ? 1 : 2)) {
console.error("Not enough divisions");
return;
}
if (extend) {
divisions.push(Number.MAX_SAFE_INTEGER);
if (divisions[0] !== 0) {
divisions.unshift(0);
}
}
const data = [];
for (let c = 0; c < divisions.length - 1; c++) {
data.push({
value: divisions[c] + "," + divisions[c + 1],
text: getDivisionText(divisions[c], divisions[c + 1]),
});
}
const ds = {
data: data,
};
const control = base
.kendoDropDownList({
dataValueField: "value",
dataTextField: "text",
optionLabel: placeholder,
dataSource: ds,
dataBound: function () {
checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
},
popup: {
appendTo: $(popupContainer),
},
change: function (e) {
let triggerFilterChanged = true;
localStorage.setItem("widget-" + name, e.sender.value());
if (typeof onChange === "function") {
triggerFilterChanged = onChange.call(self, e.sender) !== false;
}
if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
self._triggerFilterEvents(name);
}
},
})
.data("kendoDropDownList");
return (this.widgets[widgetType][name] = {
control: control,
filter: function () {
if (omitFromFilters) {
return null;
}
const v = control.value();
if (!v) {
return false;
}
// Value format is min,max
vals = v.split(",");
return "{" + varName + "} >= " + vals[0] + " AND {" + varName + "} < " + vals[1];
},
});
function getDivisionText(a, b) {
if (a === 0) {
return "Less than " + b;
}
if (b === Number.MAX_SAFE_INTEGER) {
return a + " or more";
}
return a + " - " + b;
}
};
/**
* Creates a new dropdown autocomplete list widget and populates it with all the distinct values of the given survey variable.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* @param {object} project Project to use
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} varName Name of the survey variable to use
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {function} [onChange] Method to call when the widget value changes
* @param {number} height Height of the dropdown
* @param {string} [placeholder] Placeholder value
* @param {string} [groupVar] Name(s) of the variable to group the results by
* @param {string} [popupContainer] CSS selector of the element to append the (hidden) popup containers to.
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
* @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
*
* @returns {object}
*/
this.addAutocompleteDropdownFromSurveyVar = function (
project,
name,
varName,
isPageFilter,
onChange,
height,
placeholder,
groupVar,
popupContainer,
suppressFilterEvents,
omitFromFilters
) {
popupContainer = popupContainer || "body";
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
if (server.getProjectVar(project, varName) === "undefined") {
// This might be because we're not allowed to see this variable's category
return;
}
if (isPageFilter) {
this.filters.ready[name] = false;
}
let fetchVar = varName;
let sortVar = varName;
// If we want to group by a variable then we need to ask the API for distinct responses for *that* variable, and then
// group the results by the given survey variable
let group = "";
if (groupVar) {
fetchVar = groupVar;
group = "&group={" + varName + "}";
}
const ds = {
transport: {
read: {
dataType: "json",
cache: false,
url: Server.API_ENDPOINT +
"/project/" +
project.id +
"/responses/distinct/" +
server.getProjectVar(project, fetchVar).id +
"?sort={" +
sortVar +
"}" +
group,
xhrFields: {
withCredentials: true,
},
},
},
schema: {
data: "rows",
parse: server.parseResponse,
},
};
if (groupVar) {
ds.group = {
field: groupVar,
};
}
const control = base
.kendoAutoComplete({
dataTextField: varName,
placeholder: placeholder,
height: height,
dataSource: ds,
dataBound: function () {
checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
},
popup: {
appendTo: $(popupContainer),
},
change: function (e) {
let triggerFilterChanged = true;
localStorage.setItem("widget-" + name, e.sender.value());
if (typeof onChange === "function") {
triggerFilterChanged = onChange.call(self, e.sender) !== false;
}
if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
self._triggerFilterEvents(name);
}
},
})
.data("kendoAutoComplete");
return (this.widgets[widgetType][name] = {
control: control,
filter: omitFromFilters ? null : "{" + varName + '}="#value#"',
});
};
/**
* Creates a new dropdowntree widget and populates it with the given data.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {array} data Data to display
* @param {string} valueProp Property to use as the list value
* @param {string} [textProp] Property to use as the list text
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {object} [options] Custom widget options
* @param {function} [onChange] Method to call when the widget value changes
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
* @param {boolean} [omitFromFilters] When true the dropdown will not add its value to the page filters.
*
* @returns {object}
*/
this.addDropdownTreeFromData = function (
name,
data,
valueProp,
textProp,
isPageFilter,
options,
onChange,
suppressFilterEvents,
omitFromFilters
) {
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
const clone = data.slice();
if (isPageFilter) {
this.filters.ready[name] = false;
}
const opts = $.extend({
dataValueField: valueProp,
dataTextField: textProp || valueProp,
valuePrimitive: true,
dataSource: {
data: clone,
},
dataBound: function (e) {
checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
},
popup: {
appendTo: $(popupContainer),
},
change: function (e) {
let triggerFilterChanged = true;
localStorage.setItem("widget-" + name, e.sender.value());
if (typeof onChange === "function") {
triggerFilterChanged = onChange.call(self, e.sender) !== false;
}
if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
self._triggerFilterEvents(name);
}
},
},
options
);
const control = base.kendoDropDownTree(opts).data("kendoDropDownTree");
return (this.widgets[widgetType][name] = {
control: control,
filter: function () {
return omitFromFilters ? null : this.control.value() || "1";
},
});
};
/**
* Creates a new multi-select widget and populates it with the given data.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {array} data Data to display
* @param {string} valueProp Property to use as the list value
* @param {string} [textProp] Property to use as the list text
* @param {string} [placeholder] Placeholder value
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {object} [options] Custom widget options
* @param {function} [onChange] Method to call when the widget value changes
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/multiselect} for options.
*
* @returns {object}
*/
this.addMultiSelectFromData = function (
name,
data,
valueProp,
textProp,
placeholder,
isPageFilter,
options,
onChange,
suppressFilterEvents
) {
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
const clone = data.slice();
const opts = $.extend({
dataValueField: valueProp,
dataTextField: textProp || valueProp,
dataSource: {
data: clone,
parameterMap: client.getParameterMap(),
},
dataBound: function (e) {
// Preset given values
if (options.defaultPreset) {
const data = e.sender.dataSource.data();
if (data.length) {
const presets =
options.defaultPreset === "ALL" ?
Array.from(Array(data.length), function (e, i) {
return i + 1;
}) :
options.defaultPreset;
const selected = [];
presets.forEach(function (val) {
if (typeof data[val - 1][valueProp] !== "undefined") {
selected.push(data[val - 1][valueProp]);
}
});
e.sender.value(selected);
}
}
checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
},
change: changeHandler,
},
options
);
if (placeholder) {
const item = {};
item[valueProp] = 0;
item[textProp] = placeholder;
clone.unshift(item);
}
if (isPageFilter) {
this.filters.ready[name] = false;
}
const control = base.kendoMultiSelect(opts).data("kendoMultiSelect");
return (this.widgets[widgetType][name] = {
control: control,
filter: options.filter,
});
function changeHandler(e) {
let triggerFilterChanged = true;
localStorage.setItem("widget-" + name, e.sender.value());
if (typeof onChange === "function") {
triggerFilterChanged = onChange.call(self, e.sender) !== false;
}
if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
self._triggerFilterEvents(name);
}
}
};
/**
* Creates a new multi-select widget and populates it with all the distinct values of the given survey variable.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* @param {object} project Project to use
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} surveyVarName Name of the survey variable to use
* @param {string} [placeholder] Placeholder value
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {object} [options] Custom widget options
* @param {function} [onChange] Method to call when the widget value changes
* @param {boolean} [suppressFilterEvents] When true updates to this widget will not generate filterChanged events.
* @param {string} [groupVarName] Name(s) of the variable to group the results by
* @param {array} [order] Order of the multiselect items.
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/multiselect} for options.
*
* @returns {object}
*/
this.addMultiSelectFromSurveyVar = function (
project,
name,
surveyVarName,
placeholder,
isPageFilter,
options,
onChange,
suppressFilterEvents,
groupVarName,
order
) {
// If we want to group by a variable then we need to ask the API for distinct responses for *that* variable, and then
// group the results by the given survey variable
const fetchVar = server.getProjectVar(project, groupVarName || surveyVarName);
if (typeof fetchVar === "undefined") {
// This might just be because we're not allowed to see this variable's category, so issue a warning and return
console.warn('Variable', groupVarName || surveyVarName, 'in project', project.id, 'is not visible (it might be sieved)');
return;
}
const group = groupVarName ? `&group={${surveyVarName}}` : "";
const opts = $.extend({
dataValueField: fetchVar.name,
dataTextField: fetchVar.name + "__label",
filter: "contains",
firstLoad: true,
dataSource: {
transport: {
read: {
dataType: "json",
cache: false,
url: `${Server.API_ENDPOINT}/project/${project.id}/responses/distinct/${fetchVar.id}?filter={${fetchVar.name}}!=" "${group}&sort={${fetchVar.name}}`,
xhrFields: {
withCredentials: true,
},
},
},
schema: {
data: "rows",
parse: function (response) {
// If a specific item order is specified than apply it
if (order) {
response.rows = Move.inOrder(response.rows, fetchVar.name, order);
}
for (let i = 0; i < response.rows.length; i++) {
const row = response.rows[i];
// Add data labels
row[fetchVar.name + "__label"] = client.getValueLabel(row[fetchVar.name]);
// Convert numeric strings to numbers
Object.keys(row).forEach(function (datum) {
if (typeof row[datum] === "string" && row[datum] && !isNaN(row[datum])) {
row[datum] = Number(row[datum]);
}
});
}
return response;
},
},
},
dataBound: function (e) {
// Preset given values
if (opts.firstLoad && opts.defaultPreset) {
const data = e.sender.dataSource.data();
if (data.length) {
const presets =
opts.defaultPreset === "ALL" ?
Array.from(Array(data.length), function (e, i) {
return i + 1;
}) :
opts.defaultPreset;
const selected = [];
presets.forEach(function (val) {
if (typeof data[val - 1][fetchVar.name] !== "undefined") {
selected.push(data[val - 1][fetchVar.name]);
}
});
e.sender.value(selected);
}
opts.firstLoad = false;
}
checkAllFiltersLoaded(name, self.filters.suppressAllFilterEvents || suppressFilterEvents);
},
change: function (e) {
let triggerFilterChanged = true;
localStorage.setItem("widget-" + name, e.sender.value());
if (typeof onChange === "function") {
triggerFilterChanged = onChange.call(self, e.sender) !== false;
}
if (!self.filters.suppressAllFilterEvents && !suppressFilterEvents && triggerFilterChanged && self.filters.loaded) {
self._triggerFilterEvents(name);
}
},
},
options
);
const widgetType = isPageFilter ? "static" : "disposable";
const base = $("#" + name);
if (isPageFilter) {
this.filters.ready[name] = false;
}
const control = base.kendoMultiSelect(opts).data("kendoMultiSelect");
return (this.widgets[widgetType][name] = {
control: control,
filter: function () {
const values = this.control.value();
// If we have no (or ALL) values then return false (which will omit the widget from the filter list)
const haveAllValues = values.length === this.control.dataSource.data().length;
const haveAValue = values.some((el) => el);
if (haveAllValues || !haveAValue) {
return false;
}
const haveANull = values.some((el) => el === null);
if (haveANull) {
return `({${fetchVar.name}} IN("${values.join('","')}") OR ${fetchVar.name} IS NULL)`;
}
return `{${fetchVar.name}} IN("${values.join('","')}")`;
},
});
};
/**
* Creates a new multi-select widget and populates it with all the distinct values from all the survey variables in the given variable category.
* Expects a DOM input element with an id that matches the given name to use as the base for the widget.
*
* NOTE: Each variable in the category is expected to be a nullable boolean value
*
* @param {object} project Project to use
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} categoryName Name of the survey variable category to use
* @param {string} [placeholder] Placeholder value
* @param {boolean} [isPageFilter] True if this widget is a page filter
* @param {object} [options] Custom widget options
* @param {function} [onChange] Method to call when the widget value changes
*
* @see {@link https://docs.telerik.com/kendo-ui/api/javascript/ui/multiselect} for options.
*
* @returns {object}
*/
this.addMultiSelectFromSurveyVarCategory = function (
project,
name,
categoryName,
placeholder,
isPageFilter,
options = {},
onChange
) {
const items = [];
const vars = this.getProjectVarsByCategory(project, categoryName, ["name", "label"]);
// Add a null option if requested
if (options.addNullOption) {
items.push({
name: null,
label: "n/a",
});
}
// Add all the category variables
items.push(...vars);
options.filter = function () {
const selected = this.control.value();
const filter = selected.reduce((acc, v) => {
if (v === null) {
acc.push(
vars
.reduce((acc, v) => {
acc.push(`{${v.name}} IS NULL`);
return acc;
}, [])
.join(" AND ")
);
} else {
acc.push(`{${v}}=1`);
}
return acc;
}, []);
// Return a catch-all filter if no variable filter was selected
if (filter.length === 0) {
filter.push("1");
}
return "(" + filter.join(" OR ") + ")";
};
return client.addMultiSelectFromData(
name,
items,
"name",
"label",
placeholder,
isPageFilter,
options,
onChange
);
};
/**
* Adds a video player.
* Expects a DOM element with an id that matches the given name to use as the base for the player.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} src URL of the video file to play
* @param {object} options Options to control the appearance and behaviour of the player.
*
* The following options are supported:
*
* clickAnyWhereToPlay (bool): When true the player can be toggled to play/pause by clicking anywhere in the video window.
*
* features (array): Describes the desired playback features - one or more of the following:
*
* 'play' - play/pause toggle button
* 'time' - current time in mm::ss
* 'volume' - volume control
* 'skip-start' - button to skip to the start of the video
* 'skip-backwards' - button to jump back 15 seconds
* 'skip-forwards' - button to jump forwards 15 seconds
* 'skip-end' - button to skip to the end of the video
*
* poster (string): path to an image file to show before the video is played (path is subject to CORS policy).
*
* fadeTo (string): either a CSS color value or a path to an image file (path is subject to CORS policy). The color or image
* is shown after the end of the video.
*
* captions (string): path to a VTT track file for text captions to be displayed while the video is playing (path is subject to CORS policy).
*
* close (bool): When true a close button is displayed to allow the user to hide the video element.
*
* @returns {object} Video player instance.
*/
this.addVideo = function (container, src, options) {
const containerEl = document.getElementById(container);
if (!containerEl) {
console.error("Cannot find container", containerEl);
return;
}
player = new VideoPlayer(containerEl, options);
player.load(src);
return player;
};
/**
* Adds a Bar widget.
* Expects a DOM element with an id that matches the given name to use as the base for the widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} options Options to control the appearance and behaviour of the Bar:
*
* | Option | Type | Default | Comment |
* | --- | --- | --- | --- |
* | animate | boolean | false | When true the bar expands out to its value. |
* | reverse | boolean | false | When false the bar grows to the right; when true to the left. |
* | color | string | 'grey' | Color of the value text. |
* | value | number | 0 | Value of the bar. |
* | count | number | 0 | A 'count' associated with the value of the bar e.g. a survey base that is shown in the tooltip. |
* | format | string | 'p1' | The Kendo format string used to format the bar's value for display - @see {@link https://docs.telerik.com/kendo-ui/globalization/intl/numberformatting}. |
* | height | number | 22 | The height of the bar in pixels. |
* | category | object | {} | Describes the category associated with the Bar's value. Supports the following subkeys: color (string): color of the category text. (default: 'grey'); position (string): position of the category text relative to the bar - one of 'outside-top', 'outside-bottom', 'outside-end', 'inside-end', 'inside-start'. (default: 'outside-top'); value (strong): the category text (default: null) |
* | label | object | {} | Describes the label used to display the Bar's value. Supports the following subkey: color (string): color of the value's label text. (default: 'white') |
*
* @returns {object} Bar instance.
*/
this.addBar = function (container, options) {
const containerEl = document.getElementById(container);
if (!containerEl) {
console.error("Cannot find container", containerEl);
return;
}
return new Bar(containerEl, options);
};
/**
* Summarises the selected values of a widget as a short piece of text.
*
* @param {object} ctrl A client widget's control
*
* @return {string|number} Text summary of the widget value.
*/
this.summariseControlValue = function (ctrl) {
const text = typeof ctrl.text === "function" ? ctrl.text() : null;
const value = text || ctrl.value();
if (!Array.isArray(value)) {
return value;
}
const data = ctrl.dataSource && ctrl.dataSource.data();
if (data && data.length && data.length === value.length) {
return "All";
}
switch (value.length) {
case 0:
return "None";
case 1:
return ctrl.ns === ".kendoMultiSelect" ? getLabel(ctrl.dataItems(), value[0]) : value[0];
default:
return value.length + " options chosen";
}
function getLabel(dataItems, varName) {
const v = dataItems.filter(function (item) {
return item.name === varName;
});
return v.length ? v[0].label : varName;
}
};
/**
* Returns the given widget.
*
* @param {string} name Name of the widget to retrieve
*
* @return {jQuery}
*/
this.getWidget = function (name) {
const wgt = this.widgets.static[name] || this.widgets.disposable[name];
if (!wgt) {
console.debug(name + " widget not found");
return null;
}
return wgt;
};
/**
* Returns the underlying Kendo control for the given widget.
*
* @param {string} name
*
* @return {jQuery}
*/
this.getControl = function (name) {
const wgt = this.getWidget(name);
let ctrl = wgt ? wgt.control : null;
if (!ctrl) {
return ctrl;
}
return ctrl;
};
/**
* Sets the underlying Kendo control for the given widget.
*
* @param {string} name
* @param {jQuery} ctrl
*/
this.setControl = function (name, ctrl) {
const wgt = this.getWidget(name);
if (!wgt) {
console.debug(name + " widget not found");
return;
}
wgt.control = ctrl;
};
/**
* Updates the local data in a widget.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} data New data to display
*/
this.setWidgetData = function (name, data) {
const control = this.getControl(name);
if (control) {
control.data = data;
control.refresh();
}
};
/**
* Sets a widget's options.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} value Option
* @param {*} value Option value
*/
this.setOption = function (name, option, value) {
const control = this.getControl(name);
if (control) {
control.data = data;
control.refresh();
}
};
/**
* Set a widget's dataSource to a new one containing the given data.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {object} data New data to display
*/
this.setWidgetDataSource = function (name, data) {
const control = this.getControl(name);
if (control) {
let ds;
switch (control.options.name) {
case "TreeList":
ds = new kendo.data.TreeListDataSource({
data: data,
parameterMap: client.getParameterMap(),
});
break;
case "TreeView":
ds = new kendo.data.HierarchicalDataSource({
data: data,
parameterMap: client.getParameterMap(),
});
break;
default:
ds = new kendo.data.DataSource({
transport: {
read: function (operation) {
operation.success(operation.data.data || []);
},
},
parameterMap: client.getParameterMap(),
});
}
// If this is a page filter we need to reset the 'filters-loaded' state
if (self.isPageFilter(name)) {
self.filters.loaded = false;
}
ds.read({
data: data,
});
control.setDataSource(ds);
}
};
/**
* Updates the URL used to provide the widget's data.
*
* @param {string} name Name of the widget (must be unique on a page)
* @param {string} url New remote URL
* @param {object} [query] Query parameters
* @param {bool} [cache] When true (=default) the data from the server is cached.
*/
this.setWidgetURL = function (name, url, query, cache) {
const control = this.getControl(name);
if (control) {
if (cache === false) {
query = query || {};
query["_"] = new Date().getTime();
}
const queryString = query ? server.formatQuery(query) : "";
control.dataSource.options.transport.read.url = url + "?" + queryString;
self.setPageFilterNotReady(name);
if (control.options.dataSource && control.options.dataSource.serverPaging) {
control.dataSource.page(1);
} else {
control.dataSource.read();
}
}
};
/**
* Return true if the given widget is defined as a page filter.
*
* @return {boolean}
*/
this.isPageFilter = function (name) {
return typeof self.filters.ready[name] !== "undefined";
};
/**
* Sets the given page filter state to be not ready.
*
* @return {boolean}
*/
this.setPageFilterNotReady = function (name) {
if (this.isPageFilter(name)) {
this.filters.ready[name] = false;
this.filters.loaded = false;
}
};
/**
* Returns a map containing current filter values.
*
* @param {array} [widgets] Widget filter values to return
*
* @returns {object}
*/
this.getPageFilters = function (widgets) {
const self = this;
widgets = widgets ? Convert.simpleArrayToMap(widgets, true) : this.filters.ready;
const rx = /(#(\w+)\|?(\w+)?#)/g;
const extractVar = /{[^\{]+}/;
let filters = {};
for (let name in widgets) {
const wgt = this.widgets.static[name] || this.widgets.disposable[name];
if (!wgt) {
console.error("Widget " + name + " does not exist");
continue;
}
if (!wgt.filter) {
continue;
}
// Deal with a filter function
if (typeof wgt.filter === "function") {
const result = wgt.filter.call(wgt);
if (result !== false) {
filters[name] = result;
}
continue;
}
// Deal with a missing widget control
if (!wgt.control) {
console.error(
"Widget " +
name +
" does not have an associated HTML control - do you need to add its container div to the HTML?"
);
continue;
}
let matches;
let filter = wgt.filter;
let include = true;
while ((matches = rx.exec(wgt.filter)) !== null) {
let val;
if (matches[3]) {
// Replace placeholder with the result of the control child's specified method
val = wgt.control[matches[3]].control[matches[2]]();
} else {
// Replace placeholder with the result of the control's specified method
val = wgt.control[matches[2]]();
}
// Don't include filters whose value is 'everything'
if (val === config.ALL_PLACEHOLDER || val === "") {
include = false;
break;
}
// Run the value through any custom formatter
if (wgt.formatter) {
val = wgt.formatter(val);
}
// Handle null values
if (val === self.NULL) {
const varNameMatch = extractVar.exec(wgt.filter);
if (varNameMatch && varNameMatch.length) {
filter = varNameMatch[0] + " IS NULL";
break;
}
}
// Inject it into the filter string
filter = filter.replace(matches[1], val);
}
rx.lastIndex = 0;
if (include) {
filters[name] = filter;
}
}
// Run any filter hooks to modify the filters before they are returned to the caller
if (Array.isArray(self.filters.hooks)) {
self.filters.hooks.forEach(h => {
filters = h(filters) || filters;
});
}
return filters;
};
/**
* Adds a new filter hook method to the list.
*
* @param {string} name Name of the filter hook
* @param {function} hook Hook function to be called when a filter is to be applied
*/
this.addFiltersHook = function (name, fn) {
if (self.filters.hooks.some(f => f.toString() === fn.toString())) {
return;
}
self.filters.hooks.push(fn);
};
/**
* Sets whether ot not to suppress all filter events.
*
* @param {boolean} suppress When true all filter events are suppressed.
*
* @returns {object}
*/
this.suppressAllFilterEvents = function (suppress) {
self.filters.suppressAllFilterEvents = suppress;
};
/**
* Returns the current set of page filters as a string.
*
* @param {array} [widgets] Widget filter values to return
*
* @returns {object}
*/
this.getPageFiltersAsString = function (widgets) {
return this.convertFiltersToString(this.getPageFilters(widgets));
};
/**
* Converts the given set of filters into a string.
*
* @param {object} filterMap Map of filter values to be converted.
*
* @returns {object}
*/
this.convertFiltersToString = function (filterMap) {
const filters = [];
Object.keys(filterMap).forEach(function (key) {
if (filterMap[key]) {
filters.push(filterMap[key]);
}
});
return filters.join(" AND ");
};
/**
* Calls the given function whenever a filter changes. Also calls it immediately if all filters are in the ready state.
*
* @param {function} callback Callback function.
*/
this.onFilterChanged = function (callback) {
$(window).on("filterChanged", callback.bind(this));
if (this.filters.loaded) {
callback.call(this);
}
};
/**
* Calls the given function whenever a filter changes. Also calls it immediately if all filters are in the ready state.
* Unlike onFilterChanged this is not deactivated when the page view changes.
*
* @param {function} callback Callback function.
*/
this.onPageFilterChanged = function (callback) {
$(window).on("filterChangedPersist", callback.bind(this));
if (this.filters.loaded) {
callback.call(this);
}
};
/**
* Converts the given set of Kendo filters to a string that is compatible with the data API.
*
* @param {object} f Kendo filter to convert
* @param {string} [nullValue] Optional string value to convert to an 'IS NULL' test
*
* @returns {string} An API filter that can be added to a filter collection
*/
this.convertKendoFilters = function (filter, nullValue) {
if (!filter) {
return "";
}
return (
"(" +
filter.filters
.reduce((acc, f) => {
acc.push(getFilter(f));
return acc;
}, [])
.join(` ${filter.logic} `) +
")"
);
function getFilter(f) {
let isNull = false;
if (typeof f.value === "string") {
isNull = f.value.toLowerCase() === nullValue.toLowerCase();
}
const q = typeof f === "number" ? "" : '"';
switch (f.operator) {
case "eq":
return isNull ? `{${f.field}} IS NULL` : `{${f.field}}=${q}${f.value}${q}`;
case "neq":
return isNull ? `{${f.field}} IS NOT NULL` : `{${f.field}}!=${q}${f.value}${q}`;
case "gt":
return `{${f.field}}>${q}${f.value}${q}`;
case "gte":
return `{${f.field}}>=${q}${f.value}${q}`;
case "lt":
return `{${f.field}}<${q}${f.value}${q}`;
case "lte":
return `{${f.field}}<=${q}${f.value}${q}`;
case "isnull":
return `{${f.field}} IS NULL`;
case "isnotnull":
return `{${f.field}} IS NOT NULL`;
case "isempty":
return `{${f.field}}=""`;
case "isnotempty":
return `{${f.field}}!=""`;
case "contains":
return `{${f.field}} LIKE "%${f.value}%"`;
case "doesnotcontain":
return `{${f.field}} NOT LIKE "%${f.value}%"`;
case "startswith":
return `{${f.field}} LIKE "${f.value}%"`;
case "doesnotstartwith":
return `{${f.field}} NOT LIKE "${f.value}%"`;
case "endswith":
return `{${f.field}} LIKE "%${f.value}"`;
case "doesnotendwith":
return `{${f.field}} NOT LIKE "%${f.value}"`;
default:
console.error(`Unexpected kendo filter "${f.operator}"`);
}
}
};
/**
* getSharedTooltipTemplate fetches a template that shows the chart series name, the aggregator value and the sample base.
*
* @param {string} aggregator The name of the aggregator function being used
* @param {string} categoryVar The variable name of the category
* @param {string} dataSource The variable name of the data source
*/
this.getSharedTooltipTemplate = function (aggregator, categoryVar = 'category', dataSource = 'chartDataSource') {
return `
<table>
<thead>
# let __category; #
# if (${categoryVar} instanceof Date) { #
# const __month = DateTime.getShortMonthName(${categoryVar}); #
# const __year = ${categoryVar}.getFullYear().toString().substring(2); #
# __category = __month + '-' + __year; #
# } else { #
# __category = ${categoryVar}; #
# } #
<tr>
<th colspan="4" class="tooltip-header">#: __category #</th>
</tr>
</thead>
<tbody>
# points.sort((a, b) => a.value < b.value ? 1 : -1).forEach(p => { #
# const __data = ${dataSource}.at(p.categoryIx); #
# if (__data) { #
# const __field = (p.series.variable || p.series.field).replace(/\\["|"\\]/g, ''); #
# let __countRaw = typeof __data[__field + '_count'] !== 'undefined' ? __data[__field + '_count'] : __data[__field + '__count']; #
# const __labelFormatMatches = p.series.labels.format ? p.series.labels.format.match(/:(\\w+)\}$/) : null #
# const __labelFormat = Array.isArray(__labelFormatMatches) && __labelFormatMatches.length == 2 ? __labelFormatMatches[1] : null #
# const __value = !isNaN(p.value) ? kendo.toString(p.value, p.series.valueFormat || __labelFormat || 'n1' ) : 'n/a'; #
# const __count = Number(__countRaw); #
# const __color = typeof p.series.color === 'function' ? p.series._defaults.color : p.series.color; #
# let __valStyle = ''; #
# let __valSuffix = ''; #
# if ('${aggregator}' === 'nps') { #
# __valStyle = (isNaN(p.value) || p.value < 0) ? 'color: red' : 'color: green'; #
# } #
# if ('${aggregator}' === 'csi') { #
# __valSuffix = '%'; #
# } #
# const __respStyle = config.VALID_SAMPLE_THRESHOLD && __count < config.VALID_SAMPLE_THRESHOLD ? 'color: red' : ''; #
<tr class="tooltip-series">
<td>
<div class="tooltip-color" style="background: #: __color #"></div>
</td>
<td class="tooltip-series">#: p.series.name #</td>
<td class="tooltip-value" style="#: __valStyle #">#: __value + __valSuffix #</td>
<td class="tooltip-responses" style="#: __respStyle #">#: __count.toLocaleString() # #: 'response' + (__count !== 1 ? 's' : '') #</td>
</tr>
# } #
</tbody>
# }) #
</table>
`;
};
/**
* getLatestProjectDate returns the latest date for which there is data across all the public Projects
* defined in the current portal's config.
*
* @param {object} filters Filters to apply when searching
* @param {function} cb Function to call when the request completes
*
* @returns {Date|null}
*/
this.getLatestProjectDate = function (filters, cb = (date) => { console.log(date) }) {
if (!config.PROJECTS) {
return null;
}
const requests = config.PROJECTS.reduce((req, pName) => {
const p = client.Globals.projects.find(p => p.name === pName);
if (!p.public) {
req.push(server.getResponsesAggregate(p.dateVar, p, 'max', p.dateVar, client.convertFiltersToString(filters)));
return req;
}
}, []);
$.when.apply($, requests).done(function () {
// If there's only one request then the arguments array consists solely of 3 elements containing the XHR response.
// But, if there's more than one request, the arguments array is an array of tuples with each containing the appropriate XHR response.
// This code recognises the 1-request scenario by looking for a single XHR request signature.
// If found, it normalizes the result by copying it into a single element array.
const results = arguments[1] === "success" ? [arguments] : arguments;
const datasets = Array.from(results).map(r => r[0]);
let latestDate = new Date(-8640000000000000);
datasets.forEach(data => {
const maxCandidate = new Date(data.rows[0][data.tag]);
if (maxCandidate > latestDate) {
latestDate = maxCandidate;
}
});
cb(latestDate !== new Date(-8640000000000000) ? DateTime.setEndOfDay(latestDate) : null);
});
}
/**
* Injects the given named script src into the page.
*
* @param {string} id
* @param {string} src
* @param {function} [onload] Method to call when the script has loaded
*/
this.injectScript = function (id, src, onload) {
id = "script-" + id;
// Remove script if it's already in the page
const old = document.getElementById(id);
if (old) {
old.parentNode.removeChild(old);
}
const script = document.createElement("script");
script.id = id;
script.src = src;
if (typeof onload === "function") {
script.onload = onload;
}
document.getElementsByTagName("body")[0].appendChild(script);
};
/**
* Injects the given CSS file into the page.
*
* @param {string} cssFileUrl The URL of the CSS file to inject.
* @param {function} [onload] Method to call when the CSS file has loaded.
* @param {function} [onerror] Method to call if the CSS file fails to load (default is to ignore the error)
*/
this.injectCSS = function (cssFileUrl, onload, onerror) {
if (!cssFileUrl) {
console.error('No CSS file URL given.');
return;
}
// Create a new link element
const linkElement = document.createElement('link');
linkElement.rel = 'stylesheet';
linkElement.type = 'text/css';
linkElement.href = cssFileUrl;
// Check if a callback function is provided
if (typeof onload === 'function') {
linkElement.onload = onload;
}
if (typeof onerror === 'function') {
linkElement.onerror = onerror;
} else {
// If no onerror callback is provided, prevent the browser's default error handling
linkElement.onerror = (e) => {
e.stopPropagation();
e.preventDefault();
};
}
document.getElementsByTagName('head')[0].appendChild(linkElement);
}
/**
* Returns a parameter map needed to support this widget library.
*
* @return {function}
*/
this.getParameterMap = function () {
return function (data, type) {
// Remap Kendo server-side page and sort to be compatible with the API
if (type === "read") {
return {
limit: data.take,
offset: data.skip,
sort: data.sort ?
data.sort
.map(function (sort) {
return "{" + sort.field + "} " + sort.dir;
})
.join(",") : "",
};
}
};
};
this._getDefaultDateRange = function (f) {
let range;
if (typeof f.range === 'function') {
range = f.range(self.Globals.lastDataPointDate);
} else if (!f.range) {
range = DateTime.getCurrentFinancialYear(client.Globals.lastDataPointDate, config.QUARTER_START_MONTH || DateTime.JANUARY);
} else {
range = client.Globals.datePreset[f.range || 'thisYear'];
if (!range) {
console.error(`Unknown date range '${range}'`);
return;
}
}
return range;
}
/**
* Creates a set of page filters as defined by the config.PAGE_FILTERS object.
*
* @param {string} filtersID ID of the parent HTML element for the page filters UI
* @param {string} statusID ID of the parent HTML element for the page filter status UI
* @param {Project} proj Project to be filtered
*/
this.createPageFilters = function (filtersID, statusID, proj) {
const container = document.getElementById(filtersID);
if (!container) return;
// Inject the HTML for the filter status
const status = document.getElementById(statusID);
if (status) status.innerHTML = _getPageFiltersStatusHTML();
// Inject the HTML for the filters
container.innerHTML = _getPageFiltersHTML();
// Wire up the filter reset button
const onReset = () => {
client.suppressAllFilterEvents(true);
config.PAGE_FILTERS.forEach(f => {
if (f.widget) {
const ctl = f.widget.control;
switch (f.type) {
case 'multiselect':
ctl.value([]);
break;
case 'dropdown':
case 'dropdown-rolling':
ctl.enable(true);
ctl.select(0);
break
case 'dropdown-multivar':
ctl.value('');
break;
case 'daterange':
const range = self._getDefaultDateRange(f);
if (range) {
ctl.from.control.value(range.starts);
ctl.from.control.enable(true);
ctl.to.control.value(range.ends);
ctl.to.control.enable(true);
}
if (f.byquarter) {
document.getElementById(`${f.name}-group-by-quarter`).checked = false;
}
break;
}
}
});
client.suppressAllFilterEvents(false);
$(window).triggerHandler('filterChanged');
$(window).triggerHandler('filterChangedPersist');
};
const clearBtn = document.getElementById('page-filters-reset');
clearBtn.removeEventListener('click', onReset);
clearBtn.addEventListener('click', onReset);
// Update filter status display if a filter changes
$(window).on('filterChangedPersist', () => {
config.PAGE_FILTERS.forEach(f => {
const wrapper = document.getElementById(`filter-${f.name}-wrapper`);
const status = document.getElementById(`filter-${f.name}-value`);
if (status && wrapper) {
const ctl = client.getControl(f.name);
if (!ctl) return;
switch (f.type) {
case 'daterange': {
status.textContent = `${kendo.toString(ctl.from.control.value(), config.SHORT_DATE_FORMAT)} to ${kendo.toString(ctl.to.control.value(), config.SHORT_DATE_FORMAT)}`;
break;
}
case 'dropdown-rolling': {
status.textContent = ctl.text();
break;
}
default: {
let val = ctl.value();
if (Array.isArray(val)) {
switch (val.length) {
case 0:
val = '-';
break;
case 1:
val = val[0];
break;
default:
val = val.length + ' options selected'
}
}
if (val === '__null__') val = 'n/a';
status.textContent = val || '-';
}
}
wrapper.classList.toggle('filter-is-set', status.textContent && status.textContent !== '-');
}
});
});
// Add the necessary Kendo widgets
config.PAGE_FILTERS.forEach(f => {
switch (f.type) {
case 'multiselect':
f.widget = client.addMultiSelectFromSurveyVar(proj, f.name, f.name, null, true, {
autoClose: false,
clearButton: true,
tagMode: 'single',
filter: 'contains',
placeholder: f.placeholder || 'Pick one or more...',
tagTemplate: function (data) {
return '<span>' + (data.values.length === 1 ? data.values[0] : data.values.length + ' options selected') + '</span>';
},
popup: {
appendTo: $('#' + filtersID)
}
});
break;
case 'multiselect-multivar':
f.widget = client.addMultiSelectFromSurveyVarCategory(proj, f.name, f.name, null, true, {
autoClose: false,
clearButton: true,
tagMode: 'single',
filter: 'contains',
placeholder: f.placeholder || 'Pick one or more...',
tagTemplate: function (data) {
return '<span>' + (data.values.length === 1 ? data.values[0] : data.values.length + ' options selected') + '</span>';
},
popup: {
appendTo: $('#' + filtersID)
}
}
);
break;
case 'dropdown':
if (f.data) {
f.widget = client.addDropdownFromData(f.name, f.data, 'value', 'label', null, true, null, '#' + filtersID, false, true, false);
} else {
f.widget = client.addDropdownFromSurveyVar(proj, f.name, f.name, true, null, config.ALL_PLACEHOLDER, null, '#' + filtersID, f.order);
}
break;
case 'dropdown-rolling':
const samples = [
{ label: "Monthly", value: '' },
];
if (f["three-month"] === undefined || f["three-month"]) {
samples.push({ label: "Three Month Rolling", value: `3m(${proj.dateVar})` },)
}
if (f["twelve-month"] === undefined || f["twelve-month"]) {
samples.push({ label: "Twelve Month Rolling", value: `12m(${proj.dateVar})` },)
}
if (f.ytd === undefined || f.ytd) {
samples.push({ label: "YTD Rolling", value: `ytd(${proj.dateVar},${config.QUARTER_START_MONTH || 0})` })
}
f.widget = client.addDropdownFromData(f.name, samples, 'value', 'label', null, true, (el) => { $(window).trigger('rollingChanged', el.value()) }, '#' + filtersID, false, true, false);
break;
case 'dropdown-multivar':
// Wrap all varNames in braces
const data = f.variables.map(v => ({ varName: `{${v.varName}}`, label: v.label }));
f.widget = client.addDropdownFromData(f.name, data, 'varName', 'label', config.ALL_PLACEHOLDER, true, null, '#' + filtersID, false, false, true);
break;
case 'daterange':
// addDateRange snaps the from/to values to the period the pickers select, so there's no need for a
// change handler here - @see {@link _getPeriodSnap}
f.widget = client.addDateRange(f.name, proj.dateVar, {
start: f.start || 'month',
depth: f.depth || 'month',
format: f.format || config.SHORT_DATE_FORMAT || 'M/d/yyyy',
footer: f.footer !== false,
allowFutureDates: true,
popup: {
appendTo: $('#' + filtersID)
}
}, true);
const range = self._getDefaultDateRange(f);
if (range) {
f.widget.control.from.control.value(range.starts);
f.widget.control.to.control.value(range.ends);
}
if (f.byquarter) {
document.getElementById(`${f.name}-group-by-quarter`).addEventListener('change', (e) => {
// Disable date pickers if we're grouping by quarters
f.widget.control.from.control.enable(!e.target.checked);
f.widget.control.to.control.enable(!e.target.checked);
// Disable any sampling dropdown if we're grouping by quarters
config.PAGE_FILTERS.forEach(f => {
if (f.type === "dropdown-rolling") {
const sampling = client.getControl(f.name);
sampling.select(0);
sampling.enable(!e.target.checked);
}
})
// Emit a custom event when the 'By Quarter' filter changes
$(window).triggerHandler('quarterChanged', e.target.checked);
// Trigger a view refresh
$(window).triggerHandler("filterChanged");
$(window).triggerHandler("filterChangedPersist");
});
}
break;
default:
console.error(`Unsupported page filter type '${f.type}'`);
}
});
}
// Provides a custom renders for a simplified doughnut chart (with just a single series)
this.doughnutRenderer = function (e) {
const chart = e.sender;
if (typeof e.sender._center === "undefined" || chart.options.series.length === 0) {
return;
}
const series = chart.options.series[0];
const draw = kendo.drawing;
const geom = kendo.geometry;
const circleGeometry = new geom.Circle(chart._center, chart._radius);
const valueBox = circleGeometry.bbox();
if (series.name) {
const nameBox = circleGeometry.bbox();
nameBox.origin.y -= 8;
const seriesName = new draw.Text(series.name, [0, 0], {
fill: {
color: series.data[0].color,
},
});
draw.align([seriesName], nameBox, "center");
draw.vAlign([seriesName], nameBox, "center");
chart.surface.draw(seriesName);
valueBox.origin.y += 8;
}
// Series value
const displayValue = kendo.toString(series.data[0].value, chart.options.labelFormat || "n");
const seriesValue = new draw.Text(displayValue, [0, 0], {
fill: {
color: series.labels.color,
},
font: series.labels.font,
});
draw.align([seriesValue], valueBox, "center");
draw.vAlign([seriesValue], valueBox, "center");
chart.surface.draw(seriesValue);
// Only run the chart animations once
setTimeout(function () {
e.sender.options.transitions = false;
}, 1000);
};
// Returns true if the current user is allowed to see the given view
this.allowedToSee = function (view) {
const user = this.Globals.user;
if (user.access_control !== "views" || this.isAdmin()) {
return true;
}
const viewRoles = user.roles.filter(function (r) {
return r.substr(0, 5) === "view_";
});
if (viewRoles.length === 0) {
return false;
}
return viewRoles.includes("view_" + view);
};
function checkAllFiltersLoaded(name, suppressFilterEvents) {
// Short-circuit if all filters are loaded
if (self.filters.loaded) {
return;
}
// If this is a page filter mark it as ready
if (self.isPageFilter(name)) {
self.filters.ready[name] = true;
}
// Are all the page filters ready now?
let total = 0;
let finished = 0;
for (let f in self.filters.ready) {
total++;
if (self.filters.ready[f] === true) {
finished++;
}
}
// If yes trigger events
if (finished === total) {
self.filters.loaded = true;
if (!suppressFilterEvents) {
self._triggerFilterEvents(name);
}
}
}
function createWidget(name, type, options) {
// Add a widget of the required type to the DOM
const container = $("#" + name);
if (!container) {
console.error("Cannot find container element", name);
return;
}
if (typeof container[type] !== "function") {
console.error(
"Cannot find a control of type",
type,
" - if it's a custom control have you included its control file?"
);
return null;
}
const control = container[type](options).data(type);
if (!control) {
console.error("Cannot create a control of type", type);
return;
}
self.widgets.disposable[name] = {
control: control,
data: {},
};
if (typeof options.created === "function") {
options.created.call(self.widgets.disposable[name], options, self.widgets.disposable[name].data);
}
if (typeof options.filterChanged === "function") {
const _server = new Proxy(server, getProxyHandler(control.element[0]));
self.onFilterChanged(options.filterChanged.bind(self, _server, self.widgets.disposable[name].data));
}
if (options.resizable) {
$(window).on("resize", function () {
if (control && control.element && typeof control.resize === "function") {
control.resize();
}
});
}
if (typeof options.widgetChanged === "function") {
$(window).on("widgetChanged", options.widgetChanged.bind(self));
}
return self.widgets.disposable[name];
}
/**
* getDefaultView returns the first view that the logge-in user is allowed to see.
*
* @returns {string}
*/
function getDefaultView() {
if (!config.VIEWS) {
return "";
}
for (let c = 0; c < config.VIEWS.length; c++) {
if (client.allowedToSee(config.VIEWS[c])) {
return config.VIEWS[c];
}
}
return "";
}
// This proxies the server object so it can intercept server API calls and their results
function getProxyHandler(control) {
return {
get: function (target, prop, receiver) {
// Return the original prop if it isn't a function
if (typeof target[prop] !== "function") {
return target[prop];
}
let el;
const trace = server.debugger && prop === "getResponsesAggregate";
if (trace) {
el = server.debugger.add("api", prop, control.id);
}
const orig = target[prop];
return function (...args) {
let result = orig.apply(this, args);
if (!trace) {
return result;
}
const tag = args.length > 2 ? args[0] : "n/a";
const rolling = args.length > 10 ? args[10] : "";
el.querySelector(`.__debugger-log-api-result-tag`).textContent = tag;
el.querySelector(`.__debugger-log-api-result-rolling`).textContent = rolling ?
"rolling" :
"";
return result.then((data) => {
el.querySelector(`.__debugger-log-api-result-sql`).innerHTML = getSQL(data);
el.querySelector(`.__debugger-log-api-result-data`).innerHTML = getDataTable(data);
return result;
});
};
},
};
}
function getSQL(data) {
if (!data.sql) {
return "SQL not available - switch API debug mode on and refresh.";
}
let html = `<div class="__debugger-log-sql-main">${data.sql}</div>`;
if (data.rsql) {
html += data.rsql.reduce((h, r) => {
h += `<div class="__debugger-log-sql-rolling">${r}</div>`;
return h;
}, "");
}
return html;
}
function getDataTable(data) {
if (data.rows.length === 0) {
return '<span class="__debugger-log-api-result-placeholder">No result</span>';
}
const headers = Object.keys(data.rows[0]).reduce((h, field) => {
h += `<th>${field}</th>`;
return h;
}, "");
const results = data.rows.reduce((h, r) => {
h += "<tr>";
for (const prop in r) {
h += `<td>${r[prop]}</td>`;
}
h += "</tr>";
return h;
}, "");
return `<table style="width:100%"><thead><tr>${headers}</tr></thead><tbody>${results}</tbody></table>`;
}
function _getPageFiltersHTML() {
if (!config.PAGE_FILTERS) return '';
const filters = config.PAGE_FILTERS.reduce((h, f) => {
// Build the raw HTML for the widget
let widget = '';
switch (f.type) {
case 'daterange':
widget = `<input id="${f.name}-from" title="from"/><i class="date-connector fas fa-arrow-right"></i><input id="${f.name}-to" title="to"/>`;
if (f.byquarter) {
widget += `
<label id="by-quarter-check" class="filter-label">
<div><input type="checkbox" id="${f.name}-group-by-quarter" /></div>
<div>By Quarter</div>
</label>
`;
}
break;
default:
widget = `<input id="${f.name}" />`;
}
return h += `
<li id="filter-${f.name}">
<h2>${f.label}</h2>
${widget}
</li>
`}, '');
return `
<ul id="page-filters">
${filters}
<li class="reset-button-container">
<button id="page-filters-reset" class="k-button k-info page-filter-reset" href="#">RESET FILTERS</button>
</li>
</ul>
`;
}
function _getPageFiltersStatusHTML() {
if (!config.PAGE_FILTERS) return '';
const statuses = config.PAGE_FILTERS.reduce((h, f) => h += `
<span id="filter-${f.name}-wrapper" class="filtered-by-wrapper">
<span class="filter-wrapper-label">${f.label}</span>
<span class="filter-wrapper-value"><span id="filter-${f.name}-value">-</span></span>
</span>
`, '');
return `
<div id="page-filters-status-section">
${statuses}
</div>
`;
}
}
Client.prototype._triggerFilterEvents = (name) => {
$(window).triggerHandler("filterChangedPersist", name);
$(window).triggerHandler("filterChanged", name);
}
/**
* Scans all <a> tags on the page and modifies their href attributes to a '.wr3s' version
* based on a dynamic set of conditions.
*
* Intended for staging environments where all links to a production domain
* (e.g., 'client.leadershipfactor.com') should be rewritten to their corresponding
* staging version (e.g., 'client.wr3s.leadershipfactor.com').
*
* The update happens iff:
* 1. The current page's URL (window.location) is already on a '.wr3s' subdomain.
* 2. The link's hostname ends with the target base domain (e.g., 'leadershipfactor.com').
* 3. The link's hostname does NOT already contain '.wr3s'.
*/
Client.prototype.modifyLinksIfOnStaging = function () {
// The base domain suffix to look for in hrefs.
const baseDomainSuffix = 'leadershipfactor.com';
const wr3sSubdomainPart = 'wr3s';
// Check if the current page is on a '.wr3s' subdomain.
// This ensures we only run the update logic if we're on a staging subdomain.
if (!window.location.hostname.includes(`.${wr3sSubdomainPart}.`)) {
return;
}
console.warn(`Staging detected: modifying <a> tag hrefs to include '.wr3s' subdomain...`);
// Loop through each link to check its properties.
document.querySelectorAll('a').forEach(link => {
const linkHostname = link.hostname;
const isTargetDomain = linkHostname.endsWith(baseDomainSuffix);
const isAlreadyUpdated = linkHostname.includes(`.${wr3sSubdomainPart}.`);
if (isTargetDomain && !isAlreadyUpdated) {
const originalHref = link.href;
// Construct the new hostname dynamically.
// This correctly handles both `sub.domain.com` -> `sub.wr3s.domain.com`
// and the base case `domain.com` -> `wr3s.domain.com`
const newHostname = linkHostname.replace(baseDomainSuffix, `${wr3sSubdomainPart}.${baseDomainSuffix}`);
try {
const url = new URL(originalHref);
url.hostname = newHostname;
link.href = url.toString();
} catch (e) {
// This is a failsafe for invalid hrefs like "mailto:", "tel:", etc.,
// though the `link.hostname` check should already filter them out.
console.warn(`Could not process potentially invalid href: "${originalHref}"`, e);
}
}
});
}
/**
* Normalizes the given value to an array.
*
* @param val
* @return {Array}
*/
Client.prototype.normalizeToArray = function (val) {
if (typeof val === "undefined" || val === null) {
return [];
}
return Array.isArray(val) ? val : [val];
};
/**
* Downloads the given data as a CSV
*/
Client.prototype.downloadAsCSV = function (fileName, categories, data) {
let header = ["category"];
for (let j = 0; j < data.length; j++) {
let category = data[j].name;
header.push(category);
}
const rows = [header];
for (let i = 0; i < categories.length; i++) {
const datum = [categories[i]];
for (let j = 0; j < data.length; j++) {
datum.push(data[j].data[i]);
}
rows.push(datum);
}
let csvContent = "";
rows.forEach(function (rowArray) {
let row = '"' + rowArray.join('","') + '"';
csvContent += row + "\r\n";
});
// Create a link to manage the download
let downloadLink = document.createElement("a");
let blob = new Blob(["\ufeff", csvContent]);
downloadLink.href = URL.createObjectURL(blob);
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
/**
* Given some data rows, a set of buckets and a sorted set of categories, returns a set of chart series suitable for a Kendo stacked bar chart.
*
* @param data
* @param buckets
* @param categories
*
* @return {Array}
*/
Client.prototype.buildBucketSeries = function (data, buckets, categories) {
let series = [];
Object.keys(buckets).forEach(function (bucket) {
series.push({
name: bucket,
gap: 1.2,
color: buckets[bucket] || "silver",
data: (function () {
const counts = [];
categories.forEach(function (category) {
let count = null;
if (typeof data[category] !== "undefined") {
data[category].forEach(function (row) {
if (row._bucket === bucket) {
count = row.count;
}
});
}
counts.push(count);
});
return counts;
})(),
});
});
return series;
};
/**
* escapeHTML allows HTML to be safely emitted by scripts
*/
Client.prototype.escapeHTML = function (h) {
const entityMap = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
"/": "/",
"`": "`",
"=": "=",
};
return String(h).replace(/[&<>"'`=\/]/g, function (s) {
return entityMap[s];
});
};
/**
* getValueLabel returns a label for the given value
*
* @param {any} v Data value to inspect
*
* @returns {string}
*/
Client.prototype.getValueLabel = function (v) {
switch (v) {
case null:
return "n/a";
case "":
return "<blank>";
default:
return v;
}
};
/**
* toKebabLowerCase returns a lower cased string in kebab-case
*/
Client.prototype.toKebabLowerCase = function (s) {
s = s.replace(/[\s_\-]+/g, "-");
return s.toLowerCase();
};
/**
* toTitleCase returns a string in Title Case
*/
Client.prototype.toTitleCase = function (s) {
let words = s.toLowerCase().split(/[\s_\-]+/);
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].slice(1);
}
return words.join(" ");
};
/**
* truncateMiddle returns the given string trucated to a maximum of strLen characters.
* with the separator (default: ...) in the middle.
*
* @param {string} fullStr String to truncate
* @param {number} strLen Maximum lenght of the truncated string
* @param {string} separator Separator string to use in the middle of the truncated string.
* @returns {string}
*/
Client.prototype.truncateMiddle = function (fullStr, strLen, separator) {
if (fullStr.length <= strLen) {
return fullStr;
}
separator = separator || "...";
const sepLen = separator.length,
charsToShow = strLen - sepLen,
frontChars = Math.ceil(charsToShow / 2),
backChars = Math.floor(charsToShow / 2);
return (
fullStr.substring(0, frontChars) + separator + fullStr.substring(fullStr.length - backChars)
);
};
/**
* getValidID returns a string that can be used as a valid ID for an HTML element
*/
Client.prototype.getValidID = function (s) {
s = s.replace(/\s+/g, "_");
return s.toLowerCase();
};
// toUpperCaseFirst returns the given string with an uppercase first character
Client.prototype.toUpperCaseFirst = function (s) {
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
};
// normaliseName normalises the given name to lowercase and replaces any non-alphanumeric characters with dashes
Client.prototype.normaliseName = function (s) {
return s.toLowerCase().replaceAll(/[^a-zA-Z0-9]+/g, "-");
};
// Implements a Panel decorator
function Panel() {
this.wrapper = null;
this._title = null;
}
/**
* Attaches the given element to the Panel.
*
* @param {Element} container
*/
Panel.prototype.attach = function (container) {
// Make sure the passed element is intended to be a Panel container
if (!container.dataset || typeof container.dataset.panel === "undefined") {
console.error("Provided element is missing a data-panel attribute");
return;
}
this._decorate(container);
};
/**
* Gets the title text displayed in the Panel.
*/
Panel.prototype.getTitle = function () {
return this._title ? this._title.textContent : "";
};
/**
* Sets the title displayed in the Panel.
*
* @param {string} title
*/
Panel.prototype.setTitle = function (title) {
if (this._title) {
this._title.textContent = title;
}
};
/**
* Decorates the Panel container element.
*
* @param {HTMLElement} container
*
* @return {HTMLDivElement}
*/
Panel.prototype._decorate = function (container) {
container.classList.add("panel-container");
const title = document.createElement("h2");
title.className = "panel-title";
title.textContent = container.dataset.paneltitle || "";
container.prepend(title);
this.wrapper = container;
this._title = title;
// Clean container
container.removeAttribute("data-panel");
container.removeAttribute("data-paneltitle");
};
// MainSidebar creates a left-hand Sidebar for navigation
class MainSidebar {
constructor(id, views, onSelect) {
const self = this;
const sb = document.getElementById(id);
const sbMenu = document.getElementById(id + '-menu');
const sbLinks = document.querySelector('#' + id + '-links');
if (!sb || !sbMenu || !sbLinks) {
console.error('Cannot create a sidebar - missing one of more elements');
return;
}
// Add all the views the User is allowed to see
let html = '';
Object.keys(views).forEach(function (view) {
if (client.allowedToSee(view)) {
const linkClass = views[view].linkClass ? `class="${views[view].linkClass}"` : '';
html += `<li data-view="${view}" ${linkClass}><i class="fas ${views[view].icon}"></i><span class="when-open">${views[view].label}</span></li>`;
}
});
sbLinks.insertAdjacentHTML('afterbegin', html);
const sbItems = sbLinks.querySelectorAll('li');
sb.addEventListener('click', function (e) {
if (e.target.tagName === 'LI') {
onSelect.call(self, e.target.dataset.view);
}
});
this.activateLink = function (view) {
const sbHighlight = document.getElementById(id + '-highlight');
// Update link state
for (let c = 0; c < sbItems.length; c++) {
const item = sbItems[c];
if (item.dataset.view === view) {
item.classList.add('selected');
if (sbHighlight) {
sbHighlight.style.top = item.offsetTop + 'px';
sbHighlight.style.opacity = 1;
}
} else {
item.classList.remove('selected');
}
}
};
}
}