Skip to content

Commit aed44dc

Browse files
committed
Plotly.validateTemplate
1 parent 890a324 commit aed44dc

File tree

4 files changed

+294
-2
lines changed

4 files changed

+294
-2
lines changed

src/plot_api/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,7 @@ exports.setPlotConfig = main.setPlotConfig;
3131
exports.toImage = require('./to_image');
3232
exports.validate = require('./validate');
3333
exports.downloadImage = require('../snapshot/download');
34-
exports.makeTemplate = require('./make_template');
34+
35+
var templateApi = require('./template_api');
36+
exports.makeTemplate = templateApi.makeTemplate;
37+
exports.validateTemplate = templateApi.validateTemplate;

src/plot_api/plot_template.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ exports.arrayTemplater = function(container, name, inclusionAttr) {
203203
// it's explicitly marked visible - in which case it gets NO template,
204204
// not even the default.
205205
out[inclusionAttr] = itemIn[inclusionAttr] || false;
206+
// special falsy value we can look for in validateTemplate
207+
out._template = false;
206208
return out;
207209
}
208210

src/plot_api/make_template.js renamed to src/plot_api/template_api.js

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ var dfltConfig = require('./plot_config');
3030
* @returns {object} template: the extracted template - can then be used as
3131
* `layout.template` in another figure.
3232
*/
33-
module.exports = function makeTemplate(figure) {
33+
exports.makeTemplate = function(figure) {
3434
figure = Lib.extendDeep({_context: dfltConfig}, figure);
3535
Plots.supplyDefaults(figure);
3636
var data = figure.data || [];
@@ -269,3 +269,187 @@ function getNextPath(parent, key, path) {
269269

270270
return nextPath;
271271
}
272+
273+
/**
274+
* validateTemplate: Test for consistency between the given figure and
275+
* a template, either already included in the figure or given separately.
276+
* Note that not every issue we identify here is necessarily a problem,
277+
* it depends on what you're using the template for.
278+
*
279+
* @param {object|DOM element} figure: the plot, with {data, layout} members,
280+
* to test the template against
281+
* @param {Optional(object)} template: the template, with its own {data, layout},
282+
* to test. If omitted, we will look for a template already attached as the
283+
* plot's `layout.template` attribute.
284+
*
285+
* @returns {array} array of error objects each containing:
286+
* - {string} code
287+
* error code ('missing', 'unused', 'reused', 'noLayout', 'noData')
288+
* - {string} msg
289+
* a full readable description of the issue.
290+
*/
291+
exports.validateTemplate = function(figureIn, template) {
292+
var figure = Lib.extendDeep({}, {
293+
_context: dfltConfig,
294+
data: figureIn.data,
295+
layout: figureIn.layout
296+
});
297+
var layout = figure.layout || {};
298+
if(!isPlainObject(template)) template = layout.template || {};
299+
var layoutTemplate = template.layout;
300+
var dataTemplate = template.data;
301+
var errorList = [];
302+
303+
figure.layout = layout;
304+
figure.layout.template = template;
305+
Plots.supplyDefaults(figure);
306+
307+
var fullLayout = figure._fullLayout;
308+
var fullData = figure._fullData;
309+
310+
if(!isPlainObject(layoutTemplate)) {
311+
errorList.push({code: 'layout'});
312+
}
313+
else {
314+
// TODO: any need to look deeper than the first level of layout?
315+
// I don't think so, that gets all the subplot types which should be
316+
// sufficient.
317+
for(var key in layoutTemplate) {
318+
if(key.indexOf('defaults') === -1 && isPlainObject(layoutTemplate[key]) &&
319+
!hasMatchingKey(fullLayout, key)
320+
) {
321+
errorList.push({code: 'unused', path: 'layout.' + key});
322+
}
323+
}
324+
}
325+
326+
if(!isPlainObject(dataTemplate)) {
327+
errorList.push({code: 'data'});
328+
}
329+
else {
330+
var typeCount = {};
331+
var traceType;
332+
for(var i = 0; i < fullData.length; i++) {
333+
var fullTrace = fullData[i];
334+
traceType = fullTrace.type;
335+
typeCount[traceType] = (typeCount[traceType] || 0) + 1;
336+
if(!fullTrace._fullInput._template) {
337+
// this takes care of the case of traceType in the data but not
338+
// the template
339+
errorList.push({
340+
code: 'missing',
341+
index: fullTrace._fullInput.index,
342+
traceType: traceType
343+
});
344+
}
345+
}
346+
for(traceType in dataTemplate) {
347+
var templateCount = dataTemplate[traceType].length;
348+
var dataCount = typeCount[traceType] || 0;
349+
if(templateCount > dataCount) {
350+
errorList.push({
351+
code: 'unused',
352+
traceType: traceType,
353+
templateCount: templateCount,
354+
dataCount: dataCount
355+
});
356+
}
357+
else if(dataCount > templateCount) {
358+
errorList.push({
359+
code: 'reused',
360+
traceType: traceType,
361+
templateCount: templateCount,
362+
dataCount: dataCount
363+
});
364+
}
365+
}
366+
}
367+
368+
// _template: false is when someone tried to modify an array item
369+
// but there was no template with matching name
370+
function crawlForMissingTemplates(obj, path) {
371+
for(var key in obj) {
372+
if(key.charAt(0) === '_') continue;
373+
var val = obj[key];
374+
var nextPath = getNextPath(obj, key, path);
375+
if(isPlainObject(val)) {
376+
if(Array.isArray(obj) && val._template === false && val.templateitemname) {
377+
errorList.push({
378+
code: 'missing',
379+
path: nextPath,
380+
templateitemname: val.templateitemname
381+
});
382+
}
383+
crawlForMissingTemplates(val, nextPath);
384+
}
385+
else if(Array.isArray(val) && hasPlainObject(val)) {
386+
crawlForMissingTemplates(val, nextPath);
387+
}
388+
}
389+
}
390+
crawlForMissingTemplates({data: fullData, layout: fullLayout}, '');
391+
392+
if(errorList.length) return errorList.map(format);
393+
};
394+
395+
function hasPlainObject(arr) {
396+
for(var i = 0; i < arr.length; i++) {
397+
if(isPlainObject(arr[i])) return true;
398+
}
399+
}
400+
401+
function hasMatchingKey(obj, key) {
402+
if(key in obj) return true;
403+
if(getBaseKey(key) !== key) return false;
404+
for(var key2 in obj) {
405+
if(getBaseKey(key2) === key) return true;
406+
}
407+
}
408+
409+
function format(opts) {
410+
var msg;
411+
switch(opts.code) {
412+
case 'data':
413+
msg = 'The template has no key data.';
414+
break;
415+
case 'layout':
416+
msg = 'The template has no key layout.';
417+
break;
418+
case 'missing':
419+
if(opts.path) {
420+
msg = 'There are no templates for item ' + opts.path +
421+
' with name ' + opts.templateitemname;
422+
}
423+
else {
424+
msg = 'There are no templates for trace ' + opts.index +
425+
', of type ' + opts.traceType + '.';
426+
}
427+
break;
428+
case 'unused':
429+
if(opts.path) {
430+
msg = 'The template item at ' + opts.path +
431+
' was not used in constructing the plot.';
432+
}
433+
else if(opts.dataCount) {
434+
msg = 'Some of the templates of type ' + opts.traceType +
435+
' were not used. The template has ' + opts.templateCount +
436+
' traces, the data only has ' + opts.dataCount +
437+
' of this type.';
438+
}
439+
else {
440+
msg = 'The template has ' + opts.templateCount +
441+
' traces of type ' + opts.traceType +
442+
' but there are none in the data.';
443+
}
444+
break;
445+
case 'reused':
446+
msg = 'Some of the templates of type ' + opts.traceType +
447+
' were used more than once. The template has ' +
448+
opts.templateCount + ' traces, the data has ' +
449+
opts.dataCount + ' of this type.';
450+
break;
451+
}
452+
opts.msg = msg;
453+
454+
return opts;
455+
}

test/jasmine/tests/template_test.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,106 @@ describe('template interactions', function() {
235235
.then(done);
236236
});
237237
});
238+
239+
describe('validateTemplate', function() {
240+
241+
function checkValidate(mock, expected, countToCheck) {
242+
var template = mock.layout.template;
243+
var mockNoTemplate = Lib.extendDeep({}, mock);
244+
delete mockNoTemplate.layout.template;
245+
246+
var out1 = Plotly.validateTemplate(mock);
247+
var out2 = Plotly.validateTemplate(mockNoTemplate, template);
248+
expect(out2).toEqual(out1);
249+
if(expected) {
250+
expect(countToCheck ? out1.slice(0, countToCheck) : out1)
251+
.toEqual(expected);
252+
}
253+
else {
254+
expect(out1).toBeUndefined();
255+
}
256+
}
257+
258+
var cleanMock = Lib.extendDeep({}, templateMock);
259+
cleanMock.layout.annotations.pop();
260+
cleanMock.data.pop();
261+
cleanMock.data.splice(1, 1);
262+
cleanMock.layout.template.data.bar.pop();
263+
264+
it('returns undefined when the template matches precisely', function() {
265+
checkValidate(cleanMock);
266+
});
267+
268+
it('catches all classes of regular issue', function() {
269+
var messyMock = Lib.extendDeep({}, templateMock);
270+
messyMock.data.push({type: 'box', x0: 1, y: [1, 2, 3]});
271+
messyMock.layout.template.layout.geo = {projection: {type: 'orthographic'}};
272+
messyMock.layout.template.layout.xaxis3 = {nticks: 50};
273+
messyMock.layout.template.data.violin = [{fillcolor: '#000'}];
274+
275+
checkValidate(messyMock, [{
276+
code: 'unused',
277+
path: 'layout.geo',
278+
msg: 'The template item at layout.geo was not used in constructing the plot.'
279+
}, {
280+
code: 'unused',
281+
path: 'layout.xaxis3',
282+
msg: 'The template item at layout.xaxis3 was not used in constructing the plot.'
283+
}, {
284+
code: 'missing',
285+
index: 5,
286+
traceType: 'box',
287+
msg: 'There are no templates for trace 5, of type box.'
288+
}, {
289+
code: 'reused',
290+
traceType: 'scatter',
291+
templateCount: 2,
292+
dataCount: 4,
293+
msg: 'Some of the templates of type scatter were used more than once.' +
294+
' The template has 2 traces, the data has 4 of this type.'
295+
}, {
296+
code: 'unused',
297+
traceType: 'bar',
298+
templateCount: 2,
299+
dataCount: 1,
300+
msg: 'Some of the templates of type bar were not used.' +
301+
' The template has 2 traces, the data only has 1 of this type.'
302+
}, {
303+
code: 'unused',
304+
traceType: 'violin',
305+
templateCount: 1,
306+
dataCount: 0,
307+
msg: 'The template has 1 traces of type violin' +
308+
' but there are none in the data.'
309+
}, {
310+
code: 'missing',
311+
path: 'layout.annotations[4]',
312+
templateitemname: 'nope',
313+
msg: 'There are no templates for item layout.annotations[4] with name nope'
314+
}]);
315+
});
316+
317+
it('catches missing template.data', function() {
318+
var noDataMock = Lib.extendDeep({}, cleanMock);
319+
delete noDataMock.layout.template.data;
320+
321+
checkValidate(noDataMock, [{
322+
code: 'data',
323+
msg: 'The template has no key data.'
324+
}],
325+
// check only the first error - we don't care about the specifics
326+
// uncovered after we already know there's no template.data
327+
1);
328+
});
329+
330+
it('catches missing template.data', function() {
331+
var noLayoutMock = Lib.extendDeep({}, cleanMock);
332+
delete noLayoutMock.layout.template.layout;
333+
334+
checkValidate(noLayoutMock, [{
335+
code: 'layout',
336+
msg: 'The template has no key layout.'
337+
}], 1);
338+
});
339+
340+
});

0 commit comments

Comments
 (0)