Madhuvishy has submitted this change and it was merged. Change subject: Add global default report fields ......................................................................
Add global default report fields Test plan: * select metric - see local default * set global default, select metric, see global default * select metric, set global default, see change * set global default, select metric, change metric input, doesn't revert * change timezone, no change is visible but underlying dates change Bug: T74117 Change-Id: Ieaf162fe3695a7467bd42ed09ef47c464ae29490 --- M wikimetrics/static/js/knockout.util.js M wikimetrics/static/js/reportCreate.js M wikimetrics/templates/forms/metric_configuration.html M wikimetrics/templates/report.html 4 files changed, 406 insertions(+), 136 deletions(-) Approvals: Madhuvishy: Verified; Looks good to me, approved jenkins-bot: Verified diff --git a/wikimetrics/static/js/knockout.util.js b/wikimetrics/static/js/knockout.util.js index 640cccb..2a11195 100644 --- a/wikimetrics/static/js/knockout.util.js +++ b/wikimetrics/static/js/knockout.util.js @@ -1,38 +1,93 @@ +'use strict'; /** * Custom binding that is used as follows: - * `<section data-bind="metricConfigurationForm: property"></section>` - * And works as follows: - * In the example above, property is a ko.observable or plain property that evaluates to some HTML which - * should be rendered inside the <section></section> - * The binding then sets the context for the section's child elements as the same as the current context + * + * `<section data-bind="metricConfigurationForm: { + * content: property, + * defaults: defaults + * }"></section>` + * + * Parameters + * content : is a ko.observable or plain property that evaluates to some HTML + * which should be rendered inside the <section></section> + * + * defaults : a set of observables that may control the value of the + * input elements inside the configuration HTML + * + * This binding passes the current context to the child elements */ ko.bindingHandlers.metricConfigurationForm = { - init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext){ + init: function(){ return { controlsDescendantBindings: true }; }, update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext){ - var unwrapped, childContext; - unwrapped = ko.utils.unwrapObservable(valueAccessor()); - if (unwrapped != null) { - $(unwrapped).find(':input').each(function(){ + var unwrapped = ko.unwrap(valueAccessor()), + content = ko.unwrap(unwrapped.content), + // must be careful when accessing defaults below, we don't want to re-create the form + defaults = unwrapped.defaults, + childContext = bindingContext.createChildContext(bindingContext.$data); + + if (content) { + bindingContext.subscriptions = []; + + $(content).find(':input,div.datetimepicker').each(function(){ var value = ''; var name = $(this).attr('name'); if (!name) { return; } - - if ($(this).is('[type=checkbox]')){ + + if (defaults[name] && defaults[name].peek() != null) { + value = ( + defaults[name].localDate ? + defaults[name].localDate : + defaults[name] + ).peek(); + } else if ($(this).is('[type=checkbox]')){ value = $(this).is(':checked'); + // support date time picker containers that don't have an input + } else if ($(this).is('.datetimepicker')) { + value = $(this).data('value'); } else { value = $(this).val(); } - bindingContext.$data[name] = ko.observable(value); + var inputObservable = ko.observable(value); + bindingContext.$data[name] = inputObservable; + + // set up subscriptions to the defaults + // This is important to do as subscriptions, otherwise the binding will + // think there's a dependency on defaults and run update each time a default is set, + if (defaults[name]) { + bindingContext.subscriptions.push( + defaults[name].subscribe(function(val){ + inputObservable(val); + }) + ); + } }); - $(element).html(unwrapped); - childContext = bindingContext.createChildContext(bindingContext.$data); + + $(element).html(content); ko.applyBindingsToDescendants(childContext, element); + + } else { + $(element).html(''); + + if (bindingContext.subscriptions) { + // if this update is un-selecting this metric, dispose its subscriptions + // This is important, otherwise de-selected metrics will retain their subscriptions and + // continue to receive updates + ko.bindingHandlers.metricConfigurationForm.unsubscribe(bindingContext); + } } - } + + // also clean up subscriptions on parent disposal + ko.utils.domNodeDisposal.addDisposeCallback(element, function () { + ko.bindingHandlers.metricConfigurationForm.unsubscribe(bindingContext); + }); + }, + unsubscribe: function (bindingContext) { + bindingContext.subscriptions.forEach(function (s) { s.dispose(); }); + }, }; /** @@ -58,3 +113,152 @@ } } }; + +/** + * Custom binding used as follows: + * `<div data-bind="datetimepicker: {change: newValueFunction, timezone: timezone}"> + * <input type="text" name="..." data-bind="value: observableValue"/> + * </div>` + * And works as follows: + * The change parameter gets a function that receives the new date value when it changes + * and does whatever it needs with it. + * The timezone parameter is optional and converts the value to the specified timezone + * + * NOTE: you must wrap this around an existing input field, because the common use case + * is to use WTForms to generate the form field with defaults, etc. controlled and + * tested in the python code. + */ +ko.bindingHandlers.datetimepicker = { + init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext){ + var val = valueAccessor(), + timezone = val.timezone, + zonedDate = val.value, + defaultDate = ko.unwrap(zonedDate) || val.defaultDate, + inputId = val.inputId, + // set up an observable to track the date selected + localDate = ko.observable(ko.unwrap(zonedDate)).withPausing(), + + dateFormat = ko.bindingHandlers.datetimepicker.dateFormat, + dataFormat = ko.bindingHandlers.datetimepicker.dataFormat, + + // add any boilerplate html needed to make datepicker work + container = $('<div class="input-append date"/>'); + + // since usually we'll want to update the local date, but usually + // we'll only have external access to the zoned date, we need a ref + zonedDate.localDate = localDate; + + $(element).append(container); + + container.append( + $('<input type="text">') + .attr('id', inputId) + .attr('data-format', dataFormat) + ); + + container.append( + $('<span class="add-on">' + + '<i data-time-icon="icon-time" data-date-icon="icon-calendar"></i>' + + '</span>') + ); + + container.datetimepicker({language: 'en'}); + + // the datepicker automatically sets the value of all the input elements inside + // the container to the local formatted date. To keep our hidden form element + // free of this glitch, we append it after the container + container.after( + $('<input type="hidden">') + .attr('name', inputId) + .attr('data-bind', 'value: zonedDate') + ); + + // update the local date on date changes, so it can update the computed + container.on('changeDate', function (dataWrapper) { + localDate(dataWrapper.date); + }); + + if (defaultDate) { + container.data('datetimepicker').setDate(moment.utc(defaultDate).toDate()); + } + + // write to the zoned date observable whenever the local date or timezone change + ko.computed(function () { + var local = ko.unwrap(localDate), + zone = ko.unwrap(timezone); + + if (!local) { + zonedDate(null); + return; + } + + if (!zone || !zone.value) { + return; + } + + zonedDate( + moment.utc( + // strip out the local time zone by pretending the value is in UTC + moment.utc(local).format(dateFormat) + ' ' + // then add the timezone back and parse the whole thing as a new date + + zone.value + ).format(dateFormat) + ); + }); + + // if an outsider changes the zoned date, set the date of the picker directly + var subscription = zonedDate.subscribe(function (zoned) { + var zone = ko.unwrap(timezone); + + if (!zone || !zone.value) { + return; + } + + // do the opposite of above, get the local date from the zoned + var local = moment(zoned).add( + parseInt(zone.value), 'hours' + ).format(dateFormat); + + // because the dom disposal below doesn't work, just ignore these errors + try { + // this may fire multiple times for ghost containers!! :( + container.data('datetimepicker').setDate(local); + localDate.sneakyUpdate(local); + } catch (e) { return; } + }); + + // set up the binding context on the child hidden input to make sure the zoned + // date is also available as a form element if used in a normal form + var childContext = bindingContext.createChildContext(bindingContext.$data); + childContext.zonedDate = zonedDate; + ko.applyBindingsToDescendants(childContext, element); + + // TODO: for whatever reason, this doesn't fire when the metric is de-selected + ko.utils.domNodeDisposal.addDisposeCallback(element, function () { + subscription.dispose(); + }); + + return { controlsDescendantBindings: true }; + } +}; +ko.bindingHandlers.datetimepicker.dataFormat = 'yyyy-MM-dd hh:mm:ss'; +ko.bindingHandlers.datetimepicker.dateFormat = 'YYYY-MM-DD HH:mm:ss'; + + +// hacky type of observable that can be paused so it doesn't notify subscribers +// thanks to RP Niemeier: http://stackoverflow.com/a/17984353/180664 +ko.observable.fn.withPausing = function() { + this.notifySubscribers = function() { + if (!this.pauseNotifications) { + ko.subscribable.fn.notifySubscribers.apply(this, arguments); + } + }; + + this.sneakyUpdate = function(newValue) { + this.pauseNotifications = true; + this(newValue); + this.pauseNotifications = false; + }; + + return this; +}; diff --git a/wikimetrics/static/js/reportCreate.js b/wikimetrics/static/js/reportCreate.js index 7da18f7..2f9fb46 100644 --- a/wikimetrics/static/js/reportCreate.js +++ b/wikimetrics/static/js/reportCreate.js @@ -4,9 +4,56 @@ var site = site; $(document).ready(function(){ - + 'use strict'; + + function setSelected(list){ + var bareList = ko.utils.unwrapObservable(list); + ko.utils.arrayForEach(bareList, function(item){ + item.selected = ko.observable(false); + }); + } + + function setConfigure(list){ + var bareList = ko.utils.unwrapObservable(list); + ko.utils.arrayForEach(bareList, function(item){ + item.configure = ko.observable(''); + }); + } + + function setAggregationOptions(list){ + var bareList = ko.utils.unwrapObservable(list); + ko.utils.arrayForEach(bareList, function(item){ + item.individualResults = ko.observable(false); + item.aggregateResults = ko.observable(true); + item.aggregateSum = ko.observable(true); + item.aggregateAverage = ko.observable(false); + item.aggregateStandardDeviation = ko.observable(false); + item.outputConfigured = ko.computed(function(){ + return this.individualResults() || (this.aggregateResults() && (this.aggregateSum() || this.aggregateAverage() || this.aggregateStandardDeviation())); + }, item); + }); + } + + function setTabIds(list, prefix){ + if (!prefix) { + prefix = 'should-be-unique'; + } + var bareList = ko.utils.unwrapObservable(list); + ko.utils.arrayForEach(bareList, function(item){ + + item.tabId = ko.computed(function(){ + return prefix + '-' + this.id; + }, item); + + item.tabIdSelector = ko.computed(function(){ + return '#' + prefix + '-' + this.id; + }, item); + }); + } + var utcTimezone = {name: 'UTC', value: '+00:00'}, viewModel = { + filter: ko.observable(''), cohorts: ko.observableArray([]), toggleCohort: function(cohort){ @@ -35,19 +82,25 @@ {name: 'Pacific Standard Time', value: '-08:00'}, {name: 'Hawaii Standard Time', value: '-10:00'} ]), - timezone: ko.observable(utcTimezone), // no default + timezone: ko.observable(utcTimezone), + + // global metric defaults, by property + defaults: { + 'start_date': ko.observable(), + 'end_date': ko.observable(), + 'timeseries': ko.observable(), + 'rolling_days': ko.observable(), + 'include_deleted': ko.observable(), + }, metrics: ko.observableArray([]), toggleMetric: function(metric){ - + if (metric) { if (metric.selected()){ // fetch form to configure metric with $.get('/metrics/configure/' + metric.name) - .done(site.handleWith(function(configureForm){ - metric.configure(configureForm); - enableDateTimePicker(metric); - })) + .done(site.handleWith(metric.configure)) .fail(site.failure); } else { metric.configure(''); @@ -55,9 +108,8 @@ } return true; }, - + save: function(formElement){ - var timezone = this.timezone(); if (site.hasValidationErrors()){ site.showWarning('Please configure and click Save Configuration for each selected metric.'); @@ -69,7 +121,7 @@ site.showWarning('Please select at least one cohort and one metric.'); return; } - + var metricsWithoutOutput = {}; ko.utils.arrayForEach(vm.request().responses(), function(response){ if (!response.metric.outputConfigured()){ @@ -77,12 +129,12 @@ } }); metricsWithoutOutput = site.keys(metricsWithoutOutput); - + if (metricsWithoutOutput.length){ site.showWarning(metricsWithoutOutput.join(', ') + ' do not have any output selected.'); return; } - + var form = $(formElement); var data = ko.toJSON(vm.request().responses); data = JSON.parse(data); @@ -97,13 +149,6 @@ delete response.metric.tabIdSelector; delete response.metric.selected; delete response.metric.description; - // apply timezone info - ko.utils.arrayForEach(response.metric.dateTimeFieldNames, function(name) { - response.metric[name] = moment - .utc(response.metric[name] + ' ' + timezone.value) - .format('YYYY-MM-DD HH:mm:ss'); - }); - delete response.metric.dateTimeFieldNames; }); data = JSON.stringify(data); @@ -114,27 +159,26 @@ })) .fail(site.failure); }, - - saveMetricConfiguration: function(formElement){ + + validateMetricConfiguration: function(formElement){ var metric = ko.dataFor(formElement); var form = $(formElement); var data = ko.toJS(metric); delete data.configure; - + $.ajax({ type: 'post', url: form.attr('action'), data: data }) .done(site.handleWith(function(response){ metric.configure(response); - enableDateTimePicker(metric); if (site.hasValidationErrors()){ - site.showWarning('The configuration was not all valid. Please check all the metrics below.'); + site.showWarning('The configuration was not all valid. Please check below for warnings.'); } else { - site.showSuccess('Configuration Saved'); + site.showSuccess('Configuration Valid'); } })) .fail(site.failure); } }; - + // fetch this user's cohorts $.get('/cohorts/list/') .done(site.handleWith(function(data){ @@ -146,11 +190,11 @@ viewModel.cohorts().filter(function(c){ return c.id === parseInt(location.hash.substring(1), 10); })[0].selected(true); - } catch(e) {} + } catch(e) { return; } } })) .fail(site.failure); - + // fetch the list of available metrics $.get('/metrics/list/') .done(site.handleWith(function(data){ @@ -161,24 +205,24 @@ viewModel.metrics(data.metrics); })) .fail(site.failure); - + // computed pieces of the viewModel viewModel.request = ko.observable({ recurrent: ko.observable(false), - + cohorts: ko.computed(function(){ return this.cohorts().filter(function(cohort){ return cohort.selected(); }); }, viewModel).extend({ throttle: 1 }), - + metrics: ko.computed(function(){ return this.metrics().filter(function(metric){ return metric.selected(); }); }, viewModel).extend({ throttle: 1 }) }); - + // second level computed pieces of the viewModel viewModel.request().responses = ko.computed(function(){ var request = this; @@ -195,10 +239,10 @@ ret.push(response); }); }); - + return ret; }, viewModel.request()); - + viewModel.filteredCohorts = ko.computed(function(){ if (this.cohorts().length && this.filter().length) { var filter = this.filter().toLowerCase(); @@ -209,75 +253,16 @@ } return this.cohorts(); }, viewModel); - - function setSelected(list){ - var bareList = ko.utils.unwrapObservable(list); - ko.utils.arrayForEach(bareList, function(item){ - item.selected = ko.observable(false); - }); - } - - function setConfigure(list){ - var bareList = ko.utils.unwrapObservable(list); - ko.utils.arrayForEach(bareList, function(item){ - item.configure = ko.observable(''); - }); - } - - function setAggregationOptions(list){ - var bareList = ko.utils.unwrapObservable(list); - ko.utils.arrayForEach(bareList, function(item){ - item.individualResults = ko.observable(false); - item.aggregateResults = ko.observable(true); - item.aggregateSum = ko.observable(true); - item.aggregateAverage = ko.observable(false); - item.aggregateStandardDeviation = ko.observable(false); - item.outputConfigured = ko.computed(function(){ - return this.individualResults() || (this.aggregateResults() && (this.aggregateSum() || this.aggregateAverage() || this.aggregateStandardDeviation())); - }, item); - }); - } - - function setTabIds(list, prefix){ - if (!prefix) { - prefix = 'should-be-unique'; - } - var bareList = ko.utils.unwrapObservable(list); - ko.utils.arrayForEach(bareList, function(item){ - - item.tabId = ko.computed(function(){ - return prefix + '-' + this.id; - }, item); - - item.tabIdSelector = ko.computed(function(){ - return '#' + prefix + '-' + this.id; - }, item); - }); - } - - function enableDateTimePicker(metric){ - var parentId = metric.tabId(); - var controls = $('#' + parentId + ' div.datetimepicker'); - controls.datetimepicker({language: 'en'}); - // save datetime field names for later use (timezone conversion) - metric.dateTimeFieldNames = []; - controls.each(function () { - metric.dateTimeFieldNames.push($(this).find('input').attr('name')); - }); - // TODO: this might be cleaner if it metric[name] was an observable - controls.on('changeDate', function(){ - var input = $(this).find('input'); - var name = input.attr('name'); - metric[name] = input.val(); - }); - } - + // tabs that are dynamically added won't work - fix by re-initializing - $(".sample-result .tabbable").on("click", "a", function(e){ + $('.sample-result .tabbable').on('click', 'a', function(e){ e.preventDefault(); $(this).tab('show'); }); // apply bindings - this connects the DOM with the view model constructed above ko.applyBindings(viewModel); + + // make sure any checkboxes in the Pick Defaults section are indeterminate + $('#default_include_deleted')[0].indeterminate = true; }); diff --git a/wikimetrics/templates/forms/metric_configuration.html b/wikimetrics/templates/forms/metric_configuration.html index 804c98d..560e210 100644 --- a/wikimetrics/templates/forms/metric_configuration.html +++ b/wikimetrics/templates/forms/metric_configuration.html @@ -1,4 +1,4 @@ -{% import 'forms/field_validation.html' as validation %}<form class="form-horizontal metric-configuration" method="POST" action="{{action}}" data-bind="submit: $root.saveMetricConfiguration"> +{% import 'forms/field_validation.html' as validation %}<form class="form-horizontal metric-configuration" method="POST" action="{{action}}" data-bind="submit: $root.validateMetricConfiguration"> {% for f in form %} {% if f.name != 'csrf_token' %} <div class="control-group" @@ -12,12 +12,19 @@ {% elif f.type == 'DateField' %} {{ f(**{'type':'date', 'data-bind':'value: '+f.name}) }} {% elif f.type == 'BetterDateTimeField' %} - <div class="input-append date datetimepicker" title="{{f.description}}"> - {{ f(**{'type':'text', 'data-bind':'value: '+f.name, 'data-format':'yyyy-MM-dd hh:mm:ss'}) }} - <span class="add-on"> - <i data-time-icon="icon-time" data-date-icon="icon-calendar"> - </i> - </span> + + {# The name and data-value attributes are needed so the + metric configurator binding can extract observables + #} + <div title="{{f.description}}" class="datetimepicker" + name="{{f.name}}" + data-value="{{f.data}}" + data-bind="datetimepicker: { + timezone: $root.timezone, + value: {{f.name}}, + inputId: '{{f.name}}', + defaultDate: '{{f.data}}' + }"> </div> {% else %} {{ f(**{'data-bind':'value: '+f.name}) }} diff --git a/wikimetrics/templates/report.html b/wikimetrics/templates/report.html index 1641534..c561261 100644 --- a/wikimetrics/templates/report.html +++ b/wikimetrics/templates/report.html @@ -6,19 +6,19 @@ <label class="checkbox"> <p><input type="checkbox" data-bind="checked: request().recurrent"/> Make this a <b>Public Scheduled </b> Report. This means that - it will run daily and compute results for each day it runs. - <p> + it will run daily and compute results for each day it runs. + <p> <p><small>Checking this box makes the results of this report <b>publicly</b> accessible. - There will be no way to stop the report once it starts. When using this feature, - make sure you understand these caveats or contact - <a href mailto=wikimetr...@lists.wikimedia.org> wikimetr...@lists.wikimedia.org</a> for help. + There will be no way to stop the report once it starts. When using this feature, + make sure you understand these caveats or contact + <a href mailto=wikimetr...@lists.wikimedia.org> wikimetr...@lists.wikimedia.org</a> for help. </small> </p> </label> </div> <div class="well well-small pick-cohorts"> <div class="form-inline"> - <label class="control-label"> <h4>Pick Cohorts</h4> </label> + <label class="control-label"> <h4>Pick Cohorts</h4> </label> <input type="text" placeholder="type to filter" data-bind="value: filter, valueUpdate:'afterkeydown'"/> </div> <div class="cohorts"> @@ -32,14 +32,83 @@ </ul> </div> </div> -<div class="well well-small pick-timezone"> - <div class="form-inline"> - <label class="control-label"> <h4>Pick Timezone</h4> </label> - <select data-bind="options: availableTimezones, - optionsText: function (item) { - return item.value + ' ' + item.name; - }, - value:timezone"></select> +<div class="well well-small pick-defaults"> + <h4>Pick Defaults</h4> + + <p>For reports with multiple metrics, set defaults for common parameters here. + Leave a field blank to use the metric's defaults instead.</p> + + <br/> + <div class="form-horizontal"> + <div class="control-group"> + <label for="default_timezone" class="control-label">Timezone</label> + <div class="controls"> + <select name="default_timezone" id="default_timezone" + data-bind=" + options: availableTimezones, + optionsText: function (item) { + return item.value + ' ' + item.name; + }, + value: timezone"></select> + </div> + </div> + + <!-- ko ifnot: $root.request().recurrent --> + + <div class="control-group"> + <label class="control-label" for="default_start_date">Start Date</label> + <div class="controls"> + <div data-bind="datetimepicker: { + timezone: timezone, + value: defaults.start_date, + inputId: 'default_start_date' + }"> + </div> + </div> + </div> + + <div class="control-group"> + <label class="control-label" for="default_end_date">End Date / As of Date</label> + <div class="controls"> + <div data-bind="datetimepicker: { + timezone: timezone, + value: defaults.end_date, + inputId: 'default_end_date' + }"> + </div> + </div> + </div> + + <div class="control-group"> + <label class="control-label" for="default_timeseries">Time Series by</label> + <div class="controls" title="Report results by year, month, day, or hour"> + <select data-bind="value: defaults.timeseries" + id="default_timeseries" name="default_timeseries"> + <option selected="" value="">(use metric default)</option> + <option value="none">none</option> + <option value="hour">hour</option> + <option value="day">day</option> + <option value="month">month</option> + <option value="year">year</option> + </select> + </div> + </div> + + <div class="control-group"> + <label class="control-label" for="default_rolling_days">Rolling Days</label> + <div class="controls"> + <input data-bind="value: defaults.rolling_days" id="default_rolling_days" name="default_rolling_days" type="text"> + </div> + </div> + + <div class="control-group"> + <label class="control-label" for="default_include_deleted">Include Deleted</label> + <div class="controls" title="Count revisions made on deleted pages"> + <input checked="" data-bind="checked: defaults.include_deleted" id="default_include_deleted" name="default_include_deleted" type="checkbox" value="y"> + </div> + </div> + + <!-- /ko --> </div> </div> <div class="well well-small pick-metrics"> @@ -58,7 +127,12 @@ <span data-bind="text: description"> </span> </label> - <div class="configure-metric-form" data-bind="metricConfigurationForm: configure, attr: {id: tabId() + '-configure'}"> + <div class="configure-metric-form" + data-bind="metricConfigurationForm: { + content: configure, + defaults: $root.defaults + }, + attr: {id: tabId() + '-configure'}"> </div> </div> </div> -- To view, visit https://gerrit.wikimedia.org/r/217857 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: Ieaf162fe3695a7467bd42ed09ef47c464ae29490 Gerrit-PatchSet: 13 Gerrit-Project: analytics/wikimetrics Gerrit-Branch: master Gerrit-Owner: Milimetric <dandree...@wikimedia.org> Gerrit-Reviewer: Madhuvishy <mviswanat...@wikimedia.org> Gerrit-Reviewer: jenkins-bot <> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits