diff --git a/client/charts/SDChart/Axis.js b/client/charts/SDChart/Axis.js index b020b650b..ba2e6175a 100644 --- a/client/charts/SDChart/Axis.js +++ b/client/charts/SDChart/Axis.js @@ -1,5 +1,5 @@ import {Series} from './Series'; -import {map, get} from 'lodash'; +import {map, get, sortBy, filter} from 'lodash'; /** @@ -150,6 +150,25 @@ export class Axis { * @description The maximum value on the x axis */ this.xMax = undefined; + + /** + * @ngdoc property + * @name SDChart.Axis#sortOrder + * @type {String} + * @description If undefined, do not sort, otherwise sort categories in ascending or descending + * order based on series values (if series data is provided as an object) + */ + this.sortOrder = undefined; + + /** + * @ngdoc property + * @name SDChart.Axis#excludeEmpty + * @type {Boolean} + * @description If true, remove values with 0 from the categories and series + */ + this.excludeEmpty = false; + + this._sortedCategories = undefined; } /** @@ -168,6 +187,9 @@ export class Axis { * @param {string} [options.xTitle] - The title used on the x-axis * @param {Number} [options.xMin] - The minimum value on the x axis * @param {Number} [options.xMax] - The maximum value on the x axis + * @param {String} [options.sortOrder] - If undefined, do not sort, otherwise sort categories in + * ascending or descending order based on series values (if series data is provided as an object) + * @param {Boolean} [options.excludeEmpty=false] If true, remove values with 0 from the categories and series * @return {SDChart.Axis} * @description Sets the options for the axis */ @@ -199,16 +221,58 @@ export class Axis { * @description Returns translated category field names */ getCategories() { - if (this.categories !== undefined && this.categoryField !== undefined) { - const names = this.chart.getTranslationNames(this.categoryField); + if (this._sortedCategories !== undefined) { + return this._sortedCategories; + } - return map( - this.categories, - (category) => get(names, category) || category - ); + if (this.categories === undefined) { + return undefined; } - return this.categories; + let categories = this.categories; + + if (this.series.length === 1 && typeof this.series[0].data === 'object') { + const data = this.series[0].data; + + if (this.excludeEmpty === true) { + categories = filter( + categories, + (categoryId) => data[categoryId] + ); + } + + if (this.sortOrder !== undefined) { + categories = sortBy( + categories, + (categoryId) => ( + this.sortOrder === 'asc' ? data[categoryId] : -data[categoryId] + ) + ); + } + } + + this._sortedCategories = categories; + return categories; + } + + /** + * @ngdoc method + * @name SDChart.Axis#getTranslatedCategories + * @return {Array} + * @description Returns categories sorted and translated + */ + getTranslatedCategories() { + let categories = this.getCategories(); + + if (!this.categoryField) { + return categories; + } + + const names = this.chart.getTranslationNames(this.categoryField); + + return categories.map( + (categoryId) => names[categoryId] || categoryId + ); } /** @@ -223,6 +287,15 @@ export class Axis { if (this.categories !== undefined) { axisConfig.categories = this.getCategories(); + + if (this.categoryField !== undefined) { + const names = this.chart.getTranslationNames(this.categoryField); + + axisConfig.categories = map( + axisConfig.categories, + (categoryId) => get(names, categoryId) || categoryId + ); + } } if (this.allowDecimals !== undefined) { diff --git a/client/charts/SDChart/Chart.js b/client/charts/SDChart/Chart.js index e46f52b28..fc374752f 100644 --- a/client/charts/SDChart/Chart.js +++ b/client/charts/SDChart/Chart.js @@ -33,8 +33,12 @@ export class Chart { * @param {Function} [options.tooltipFormatter] - Callback function to dynamically generate tooltips * @param {Function} [options.onPointClick] - Callback function when a point is clicked * @param {Object} [options.dataLabelConfig] - Custom config for dataLabels - * @params {Boolean} [options.invertAxes=false] - Invert the X and Y axes - * @params {Function} [options.legendFormatter] - Callback function to dynamically generate legend labels + * @param {Boolean} [options.invertAxes=false] - Invert the X and Y axes + * @param {Function} [options.legendFormatter] - Callback function to dynamically generate legend labels + * @param {String} [options.legendFormat] - The legend point format + * @param {Boolean} [options.shadow=true] - Creates a shadow around the chart container + * @param {Boolean} [options.exporting] - If false, then disables exporting options + * @param {Array} [options.legendOffset] - X/Y Offset for the legend position * @description Initialise the data for the chart config */ constructor(options) { @@ -66,6 +70,10 @@ export class Chart { options.invertAxes = false; } + if (!('shadow' in options)) { + options.shadow = true; + } + /** * @ngdoc property * @name SDChart.Chart#id @@ -271,6 +279,38 @@ export class Chart { * @description Callback function to dynamically generate legend labels */ this.legendFormatter = options.legendFormatter; + + /** + * @ngdoc property + * @name SDChart.Chart#legendFormat + * @type {String} + * @description The legend point format + */ + this.legendFormat = options.legendFormat; + + /** + * @ngdoc property + * @name SDChart.Chart#shadow + * @type {Boolean} + * @description Creates a shadow around the chart container + */ + this.shadow = options.shadow; + + /** + * @ngdoc property + * @name SDChart.Chart#exporting + * @type {Boolean} + * @description If false, then disables exporting options + */ + this.exporting = options.exporting; + + /** + * @ngdoc property + * @name SDChart.Chart#legendOffset + * @ype {Array} + * @description X/Y Offset for the legend position + */ + this.legendOffset = options.legendOffset; } /** @@ -292,11 +332,14 @@ export class Chart { genTitleConfig(config) { const title = this.getTitle(); + if (!get(config, 'title')) { + config.title = {}; + } + if (title !== undefined) { - if (!get(config, 'title')) { - config.title = {}; - } config.title.text = title; + } else if (!get(config, 'title.text')) { + config.title.text = ''; } return config; @@ -342,16 +385,28 @@ export class Chart { config.legend = {}; } - if (this.legendTitle === undefined) { + if (!this.legendTitle && !this.legendFormatter && !this.legendFormat) { config.legend.enabled = false; } else { config.legend.enabled = true; - config.legend.title = {text: this.legendTitle}; - } - - if (this.legendFormatter !== undefined) { - config.legend.labelFormatter = this.legendFormatter; config.legend.useHTML = true; + + if (this.legendTitle) { + config.legend.title = {text: this.legendTitle}; + } + + if (this.legendFormatter) { + config.legend.labelFormatter = this.legendFormatter; + } + + if (this.legendFormat) { + config.legend.labelFormat = this.legendFormat; + } + + if (this.legendOffset) { + config.legend.x = this.legendOffset[0]; + config.legend.y = this.legendOffset[1]; + } } return config; @@ -368,17 +423,22 @@ export class Chart { config.tooltip = {}; } - if (this.tooltipHeader !== undefined) { - config.tooltip.headerFormat = this.tooltipHeader; - } + if (!this.tooltipHeader && !this.tooltipPoint && !this.tooltipFormatter) { + config.tooltip.enabled = false; + } else { + config.tooltip.enabled = true; + if (this.tooltipHeader !== undefined) { + config.tooltip.headerFormat = this.tooltipHeader; + } - if (this.tooltipPoint !== undefined) { - config.tooltip.pointFormat = this.tooltipPoint; - } + if (this.tooltipPoint !== undefined) { + config.tooltip.pointFormat = this.tooltipPoint; + } - if (this.tooltipFormatter !== undefined) { - config.tooltip.formatter = this.tooltipFormatter; - config.tooltip.useHTML = true; + if (this.tooltipFormatter !== undefined) { + config.tooltip.formatter = this.tooltipFormatter; + config.tooltip.useHTML = true; + } } return config; @@ -529,6 +589,18 @@ export class Chart { return config; } + /** + * @ngdoc method + * @name SDChart.Chart#genExportingConfig + * @param {Object} config + * @description Generates the exporting config to use for the chart + */ + genExportingConfig(config) { + if (this.exporting === false) { + config.exporting = {enabled: false}; + } + } + /** * @ngdoc method * @name SDChart.Chart#genHighchartsConfig @@ -538,6 +610,7 @@ export class Chart { genHighchartsConfig(config) { config.id = this.id; config.type = this.chartType; + config.shadow = this.shadow; this.genChartConfig(config); this.genTitleConfig(config); @@ -546,6 +619,7 @@ export class Chart { this.genLegendConfig(config); this.genTooltipConfig(config); this.genPlotConfig(config); + this.genExportingConfig(config); this.axis.forEach((axis) => axis.genConfig(config)); @@ -563,7 +637,7 @@ export class Chart { const headers = [axis.xTitle, axis.yTitle]; const rows = []; - forEach(axis.getCategories(), (category, index) => { + forEach(axis.getTranslatedCategories(), (category, index) => { rows.push([ category, axis.series[0].data[index], @@ -597,7 +671,7 @@ export class Chart { 'Total Stories' ); - const rows = axis.getCategories().map((category) => ([category])); + const rows = axis.getTranslatedCategories().map((category) => ([category])); forEach(axis.series, (series) => { series.getData().forEach((count, index) => { diff --git a/client/charts/SDChart/Series.js b/client/charts/SDChart/Series.js index 42511e9a5..1b3bcefd5 100644 --- a/client/charts/SDChart/Series.js +++ b/client/charts/SDChart/Series.js @@ -92,18 +92,63 @@ export class Series { * @description Config to use for the data labels of this series */ this.dataLabelConfig = undefined; + + /** + * @ngdoc property + * @name SDChart.Series#colours + * @type {Array|Object} + * @description Array or Object of colours to use for the data points + */ + this.colours = undefined; + + /** + * @ngdoc property + * @name SDChart.Series#size + * @type {Number} + * @description Size of the chart + */ + this.size = undefined; + + /** + * @ngdoc property + * @name SDChart.Series#semiCircle + * @type {Boolean} + * @description If the chart type is a pie, then render a semi circle + */ + this.semiCircle = undefined; + + /** + * @ngdoc property + * @name SDChart.Series#center + * @type {Array} + * @description The x and y offset of the center of the chart + */ + this.center = undefined; + + /** + * @ngdoc property + * @name SDChart.Series#showInLegend + * @type {Boolean} + * @description If true, show the point names in the legend + */ + this.showInLegend = undefined; } /** * @ngdoc method * @name SDChart.Series#setOptions * @param {string} [options.type=this.axis.defaultChartType] - The chart type - * @param {Object|Array} options.data - The data to add to the series + * @param {Object|Array} [options.data] - The data to add to the series * @param {string} [options.field] - The field type for the data * @param {string} [options.name] - The field name for the data * @param {Number} [options.stack] - The stack number * @param {string} [options.stackType] - The type of stacking to perform * @param {string} [options.colourIndex] - The colour index to use for this series + * @param {Array|Object} [options.colours] - Array or Object of colours to use for the data points + * @param {Number} [options.size] - Size of the chart + * @param {Boolean} [options.semiCircle] - If the chart type is a pie, then render a semi circle + * @param {Array} [options.center] - The x and y offset of the center of the chart + * @param {Boolean} [options.showInLegend] - If true, show the point names in the legend * @return {SDChart.Series} * @description Sets the options for the series */ @@ -121,17 +166,18 @@ export class Series { * @return {Array} * @description Returns the data for this series */ - getData() { - if (this.data === undefined) { - return undefined; - } else if (Array.isArray(this.data)) { + getData(series) { + if (this.data === undefined || Array.isArray(this.data)) { return this.data; - } + } else if (this.axis.categories !== undefined) { + const names = this.chart.getTranslationNames(this.axis.categoryField); - if (this.axis.categories !== undefined) { - return map( - this.axis.categories, - (source) => get(this.data, source) || 0 + return this.axis.getCategories().map( + (categoryId, index) => ({ + name: names[categoryId] || categoryId, + y: get(this.data, categoryId) || 0, + className: get(this.colours, categoryId) || get(this.colours, index) || '', + }) ); } @@ -167,7 +213,7 @@ export class Series { */ setDataConfig(series) { const name = this.getName(); - const data = this.getData(); + const data = this.getData(series); if (name !== undefined) { series.name = name; @@ -197,6 +243,25 @@ export class Series { if (this.axis.pointInterval !== undefined) { series.pointInterval = this.axis.pointInterval; } + + if (this.size) { + series.size = this.size; + } + + if (series.type === 'pie' && this.semiCircle !== undefined) { + series.startAngle = -90; + series.endAngle = 90; + series.innerSize = '50%'; + series.slicedOffset = 0; + } + + if (this.center !== undefined) { + series.center = this.center; + } + + if (this.showInLegend !== undefined) { + series.showInLegend = this.showInLegend; + } } /** @@ -232,6 +297,7 @@ export class Series { this.setPointConfig(series); this.setStyleConfig(series); + return series; } } diff --git a/client/charts/SDChart/tests/SDChart.Axis.spec.js b/client/charts/SDChart/tests/SDChart.Axis.spec.js index b18fdf7a7..fcc50dec7 100644 --- a/client/charts/SDChart/tests/SDChart.Axis.spec.js +++ b/client/charts/SDChart/tests/SDChart.Axis.spec.js @@ -161,4 +161,122 @@ describe('SDChart.Axis', () => { }], })); }); + + it('can sort categories', () => { + let chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + sortOrder: 'asc', + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + xAxis: [{ + type: 'category', + allowDecimals: false, + categories: ['a', 'c', 'b'], + title: {text: 'Category'}, + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + sortOrder: 'desc', + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + xAxis: [{ + type: 'category', + allowDecimals: false, + categories: ['b', 'c', 'a'], + title: {text: 'Category'}, + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + xAxis: [{ + type: 'category', + allowDecimals: false, + categories: ['a', 'b', 'c'], + title: {text: 'Category'}, + }], + })); + }); + + it('can exclude empty data', () => { + let chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + excludeEmpty: true, + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 0, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + xAxis: [{ + type: 'category', + allowDecimals: false, + categories: ['a', 'c'], + title: {text: 'Category'}, + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + excludeEmpty: false, + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 0, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + xAxis: [{ + type: 'category', + allowDecimals: false, + categories: ['a', 'b', 'c'], + title: {text: 'Category'}, + }], + })); + }); }); diff --git a/client/charts/SDChart/tests/SDChart.Chart.spec.js b/client/charts/SDChart/tests/SDChart.Chart.spec.js index 71ebfc76e..b59d6fbee 100644 --- a/client/charts/SDChart/tests/SDChart.Chart.spec.js +++ b/client/charts/SDChart/tests/SDChart.Chart.spec.js @@ -14,9 +14,11 @@ describe('SDChart.Chart', () => { chart: {}, time: {useUTC: true}, legend: {enabled: false}, - tooltip: {}, + tooltip: {enabled: false}, plotOptions: {series: {dataLabels: {enabled: false}}}, fullHeight: false, + shadow: true, + title: {text: ''}, }); expect(genConfig({ @@ -33,7 +35,7 @@ describe('SDChart.Chart', () => { chart: {}, time: {useUTC: true}, legend: {enabled: false}, - tooltip: {}, + tooltip: {enabled: false}, plotOptions: { series: { dataLabels: {enabled: false}, @@ -44,6 +46,7 @@ describe('SDChart.Chart', () => { title: {text: 'Default Title'}, subtitle: {text: 'Default Subtitle'}, fullHeight: false, + shadow: true, }); }); @@ -93,6 +96,7 @@ describe('SDChart.Chart', () => { legend: { enabled: true, title: {text: 'Test Legend'}, + useHTML: true, }, })); @@ -103,6 +107,7 @@ describe('SDChart.Chart', () => { }) ).toEqual(jasmine.objectContaining({ tooltip: { + enabled: true, headerFormat: 'Tool Header {point.x}', pointFormat: 'Tool Point {point.y}', }, diff --git a/client/charts/SDChart/tests/SDChart.Series.spec.js b/client/charts/SDChart/tests/SDChart.Series.spec.js index 7d9d3b6b3..a22a614af 100644 --- a/client/charts/SDChart/tests/SDChart.Series.spec.js +++ b/client/charts/SDChart/tests/SDChart.Series.spec.js @@ -147,7 +147,11 @@ describe('SDChart.Series', () => { xAxis: 0, type: 'bar', name: 'Test Data', - data: [6, 10, 2], + data: [ + {name: 'b', y: 6, className: ''}, + {name: 'c', y: 10, className: ''}, + {name: 'a', y: 2, className: ''}, + ], }], })); @@ -184,7 +188,371 @@ describe('SDChart.Series', () => { xAxis: 0, type: 'bar', name: 'Category', - data: [6, 10, 2], + data: [ + {name: 'Basketball', y: 6, className: ''}, + {name: 'Cricket', y: 10, className: ''}, + {name: 'Advisories', y: 2, className: ''}, + ], + }], + })); + }); + + it('can sort data', () => { + let chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + sortOrder: 'asc', + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'c', y: 3, className: ''}, + {name: 'b', y: 10, className: ''}, + ], + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + sortOrder: 'desc', + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + {name: 'a', y: 1, className: ''}, + ], + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + }); + + it('can exclude empty data', () => { + let chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + excludeEmpty: true, + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 0, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + excludeEmpty: false, + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 0, c: 3}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 0, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + }); + + it('can set colours', () => { + let chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + colours: {a: 'sda-blue', b: 'sda-green', c: 'sda-orange'}, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'a', y: 1, className: 'sda-blue'}, + {name: 'b', y: 10, className: 'sda-green'}, + {name: 'c', y: 3, className: 'sda-orange'}, + ], + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + colours: ['sda-blue', 'sda-green', 'sda-orange'], + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'a', y: 1, className: 'sda-blue'}, + {name: 'b', y: 10, className: 'sda-green'}, + {name: 'c', y: 3, className: 'sda-orange'}, + ], + }], + })); + }); + + it('can set the size', () => { + const chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + size: 260, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + size: 260, + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + }); + + it('can set pie chart to be a semi-circle', () => { + let chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + defaultChartType: 'pie', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + semiCircle: true, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'pie', + startAngle: -90, + endAngle: 90, + innerSize: '50%', + slicedOffset: 0, + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + + // Ignores semiCircle if chart type is not a pie chart + chart = new SDChart.Chart({}); + chart.addAxis() + .setOptions({ + type: 'category', + defaultChartType: 'bar', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + semiCircle: true, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + }); + + it('can set the center of the chart', () => { + const chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + center: ['50%', '75%'], + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + center: ['50%', '75%'], + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + }); + + it('can set showInLegend of the chart', () => { + let chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + showInLegend: true, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + showInLegend: true, + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + ], + }], + })); + + chart = new SDChart.Chart({}); + + chart.addAxis() + .setOptions({ + type: 'category', + categories: ['a', 'b', 'c'], + xTitle: 'Category', + }) + .addSeries() + .setOptions({ + data: {a: 1, b: 10, c: 3}, + showInLegend: false, + }); + + expect(chart.genConfig()).toEqual(jasmine.objectContaining({ + series: [{ + xAxis: 0, + type: 'bar', + showInLegend: false, + data: [ + {name: 'a', y: 1, className: ''}, + {name: 'b', y: 10, className: ''}, + {name: 'c', y: 3, className: ''}, + ], }], })); }); diff --git a/client/charts/directives/Chart.js b/client/charts/directives/Chart.js index e6dcc9bdd..3b80129b7 100644 --- a/client/charts/directives/Chart.js +++ b/client/charts/directives/Chart.js @@ -1,4 +1,4 @@ -Chart.$inject = ['chartManager', '$timeout', '$sce']; +Chart.$inject = ['chartManager', '$timeout', '$sce', 'lodash']; /** * @ngdoc directive @@ -8,19 +8,25 @@ Chart.$inject = ['chartManager', '$timeout', '$sce']; * @requires $timeout * @description A directive that renders a Highcharts instance given its config */ -export function Chart(chartManager, $timeout, $sce) { +export function Chart(chartManager, $timeout, $sce, _) { return { scope: {config: '<'}, template: require('../views/chart.html'), link: function(scope, element, attrs) { let target = element.find('div'); - scope.$watch('config', (config) => { - render(config, config.id); + scope.$watch('config', (newConfig, oldConfig) => { + if (newConfig) { + render(newConfig, newConfig.id); + } else if (_.get(oldConfig, 'id')) { + chartManager.destroy(oldConfig.id); + } }); scope.$on('$destroy', () => { - chartManager.destroy(scope.config.id); + if (_.get(scope, 'config.id')) { + chartManager.destroy(scope.config.id); + } }); /** diff --git a/client/charts/directives/ChartColourPicker.js b/client/charts/directives/ChartColourPicker.js new file mode 100644 index 000000000..0bd0e3ab6 --- /dev/null +++ b/client/charts/directives/ChartColourPicker.js @@ -0,0 +1,73 @@ +ChartColourPicker.$inject = ['gettext']; + + +export const CHART_COLOURS = { + BLACK: 'sda-black', + GRAY_DARKER: 'sda-gray-darker', + GRAY_DARK: 'sda-gray-dark', + GRAY_MEDIUM: 'sda-gray-medium', + GRAY: 'sda-gray', + GRAY_NEUTRAL: 'sda-gray-neutral', + GRAY_TEXT: 'sda-gray-text', + GRAY_LIGHT: 'sda-gray-light', + GRAY_LIGHTER: 'sda-gray-lighter', + WHITE: 'sda-white', + BLUE: 'sda-blue', + BLUE_MEDIUM: 'sda-blue-medium', + BLUE_DARK: 'sda-blue-dark', + GREEN: 'sda-green', + RED: 'sda-red', + YELLOW: 'sda-yellow', + ORANGE: 'sda-orange', + PURPLE: 'sda-purple', + FERN_GREEN: 'sda-fern-green', + OLD_GOLD: 'sda-old-gold', + DARK_ORNGE: 'sda-dark-orange', + FIRE_BRICK: 'sda-fire-brick', + DEEP_PINK: 'sda-deep-pink', + DARK_MAGENTA: 'sda-dark-magenta', + DARK_VIOLET: 'sda-dark-violet', + NAVY: 'sda-navy', +}; + +export function ChartColourPicker(gettext) { + return { + scope: { + field: '=', + label: '=', + }, + replace: true, + transclude: true, + template: require('../views/chart-colour-picker.html'), + link: function(scope) { + scope.colours = [ + {name: gettext('Black'), colour: CHART_COLOURS.BLACK}, + {name: gettext('Dark Gray'), colour: CHART_COLOURS.GRAY_DARK}, + {name: gettext('Darker Gray'), colour: CHART_COLOURS.GRAY_DARKER}, + {name: gettext('Medium Gray'), colour: CHART_COLOURS.GRAY_MEDIUM}, + {name: gettext('Gray'), colour: CHART_COLOURS.GRAY}, + {name: gettext('Neutral Gray'), colour: CHART_COLOURS.GRAY_NEUTRAL}, + {name: gettext('Gray Text'), colour: CHART_COLOURS.GRAY_TEXT}, + {name: gettext('Light Gray'), colour: CHART_COLOURS.GRAY_LIGHT}, + {name: gettext('Lighter Gray'), colour: CHART_COLOURS.GRAY_LIGHTER}, + {name: gettext('White'), colour: CHART_COLOURS.WHITE}, + {name: gettext('Blue'), colour: CHART_COLOURS.BLUE}, + {name: gettext('Medium Blue'), colour: CHART_COLOURS.BLUE_MEDIUM}, + {name: gettext('Dark Blue'), colour: CHART_COLOURS.BLUE_DARK}, + {name: gettext('Green'), colour: CHART_COLOURS.GREEN}, + {name: gettext('Red'), colour: CHART_COLOURS.RED}, + {name: gettext('Yellow'), colour: CHART_COLOURS.YELLOW}, + {name: gettext('Orange'), colour: CHART_COLOURS.ORANGE}, + {name: gettext('Purple'), colour: CHART_COLOURS.PURPLE}, + {name: gettext('Fern Green'), colour: CHART_COLOURS.FERN_GREEN}, + {name: gettext('Old Gold'), colour: CHART_COLOURS.OLD_GOLD}, + {name: gettext('Dark Orange'), colour: CHART_COLOURS.DARK_ORNGE}, + {name: gettext('Fire Brick'), colour: CHART_COLOURS.FIRE_BRICK}, + {name: gettext('Deep Pink'), colour: CHART_COLOURS.DEEP_PINK}, + {name: gettext('Dark Magenta'), colour: CHART_COLOURS.DARK_MAGENTA}, + {name: gettext('Dark Violet'), colour: CHART_COLOURS.DARK_VIOLET}, + {name: gettext('Navy'), colour: CHART_COLOURS.NAVY}, + ]; + }, + }; +} diff --git a/client/charts/directives/ChartOptions.js b/client/charts/directives/ChartOptions.js new file mode 100644 index 000000000..c40e8ef87 --- /dev/null +++ b/client/charts/directives/ChartOptions.js @@ -0,0 +1,66 @@ +ChartOptions.$inject = ['chartConfig']; + +export const CHART_FIELDS = { + TITLE: 'title', + SUBTITLE: 'subtitle', + TYPE: 'type', + SORT: 'sort', + PAGE_SIZE: 'page_size', +}; + +export const DEFAULT_CHART_FIELDS = [ + CHART_FIELDS.TITLE, + CHART_FIELDS.SUBTITLE, + CHART_FIELDS.TYPE, + CHART_FIELDS.SORT, +]; + +export const CHART_TYPES = { + BAR: 'bar', + COLUMN: 'column', + TABLE: 'table', + AREA: 'area', + LINE: 'line', + PIE: 'pie', + SCATTER: 'scatter', + SPLINE: 'spline', +}; + +export const DEFAULT_CHART_TYPES = [ + CHART_TYPES.BAR, + CHART_TYPES.COLUMN, + CHART_TYPES.TABLE, +]; + +export function ChartOptions(chartConfig) { + return { + scope: { + params: '=', + fields: '=?', + chartTypes: '=?', + titlePlaceholder: '=?', + subtitlePlaceholder: '=?', + updateChartConfig: '=?', + }, + template: require('../views/chart-form-options.html'), + link: function(scope) { + if (angular.isUndefined(scope.fields)) { + scope.fields = DEFAULT_CHART_FIELDS; + } + + scope.enabled = {}; + + Object.values(CHART_FIELDS).forEach( + (field) => { + scope.enabled[field] = scope.fields.indexOf(field) > -1; + } + ); + + if (angular.isUndefined(scope.chartTypes)) { + scope.chartTypes = DEFAULT_CHART_TYPES; + } + + scope.types = chartConfig.filterChartTypes(scope.chartTypes); + }, + }; +} diff --git a/client/charts/directives/index.js b/client/charts/directives/index.js index e6edef497..8cf177e1d 100644 --- a/client/charts/directives/index.js +++ b/client/charts/directives/index.js @@ -1,3 +1,5 @@ export {Chart} from './Chart'; export {ChartContainer} from './ChartContainer'; export {Table} from './Table'; +export {ChartOptions} from './ChartOptions'; +export {ChartColourPicker} from './ChartColourPicker'; diff --git a/client/charts/index.js b/client/charts/index.js index 8db475021..3ce3f640c 100644 --- a/client/charts/index.js +++ b/client/charts/index.js @@ -46,5 +46,7 @@ angular.module('superdesk.analytics.charts', []) .directive('sdaChart', directives.Chart) .directive('sdaChartContainer', directives.ChartContainer) .directive('sdaTable', directives.Table) + .directive('sdaChartOptions', directives.ChartOptions) + .directive('sdaChartColourPicker', directives.ChartColourPicker) .run(cacheIncludedTemplates); diff --git a/client/charts/services/ChartConfig.js b/client/charts/services/ChartConfig.js index 1f7412eb9..2db69b549 100644 --- a/client/charts/services/ChartConfig.js +++ b/client/charts/services/ChartConfig.js @@ -143,6 +143,7 @@ export function ChartConfig( this.chartType = chartType; this.sources = []; this.sortOrder = 'desc'; + this.shadow = true; } /** @@ -331,6 +332,7 @@ export function ChartConfig( subtitle: this.getSubtitle(), defaultConfig: self.defaultConfig, fullHeight: true, + shadow: this.shadow, }); chart.translations = self.translations; diff --git a/client/charts/styles/charts.scss b/client/charts/styles/charts.scss index f5c8d11f9..8e6268770 100644 --- a/client/charts/styles/charts.scss +++ b/client/charts/styles/charts.scss @@ -238,3 +238,158 @@ $colors: #7cb5ec #434348 #90ed7d #f7a35c #8085e9 #f15c80 #e4d354 #2b908f #f45b5b .highcharts-data-label-color-9 .highcharts-data-label-box {@extend .highcharts-color-9;} .highcharts-data-label-color-9.highcharts-data-label text {fill: $gray;} +.sda-black { + fill: $black; + stroke: $black; + background-color: $black !important; +} + +.sda-gray-darker { + fill: $grayDarker; + stroke: $grayDarker; + background-color: $grayDarker !important; +} + +.sda-gray-dark { + fill: $grayDark; + stroke: $grayDark; + background-color: $grayDark !important; +} + +.sda-gray-medium { + fill: $grayMedium; + stroke: $grayMedium; + background-color: $grayMedium !important; +} + +.sda-gray { + fill: $gray; + stroke: $gray; + background-color: $gray !important; +} + +.sda-gray-neutral { + fill: $grayNeutral; + stroke: $grayNeutral; + background-color: $grayNeutral !important; +} + +.sda-gray-text { + fill: $grayText; + stroke: $grayText; + background-color: $grayText !important; +} + +.sda-gray-light { + fill: $grayLight; + stroke: $grayLight; + background-color: $grayLight !important; +} + +.sda-gray-lighter { + fill: $grayLighter; + stroke: $grayLighter; + background-color: $grayLighter !important; +} + +.sda-white { + fill: $white; + stroke: $white; + background-color: $white !important; +} + +.sda-blue { + fill: $sd-blue; + stroke: $sd-blue; + background-color: $sd-blue !important; +} + +.sda-blue-medium { + fill: $sd-blueMedium; + stroke: $sd-blueMedium; + background-color: $sd-blueMedium !important; +} + +.sda-blue-dark { + fill: $sd-blueDark; + stroke: $sd-blueDark; + background-color: $sd-blueDark !important; +} + +.sda-green { + fill: $green; + stroke: $green; + background-color: $green !important; +} + +.sda-red { + fill: $red; + stroke: $red; + background-color: $red !important; +} + +.sda-yellow { + fill: $yellow; + stroke: $yellow; + background-color: $yellow !important; +} + +.sda-orange { + fill: $orange; + stroke: $orange; + background-color: $orange !important; +} + +.sda-purple { + fill: $purple; + stroke: $purple; + background-color: $purple !important; +} + +.sda-fern-green { + fill: $fernGreen; + stroke: $fernGreen; + background-color: $fernGreen !important; +} + +.sda-old-gold { + fill: $oldGold; + stroke: $oldGold; + background-color: $oldGold !important; +} + +.sda-dark-orange { + fill: $darkOrange; + stroke: $darkOrange; + background-color: $darkOrange !important; +} + +.sda-fire-brick { + fill: $fireBrick; + stroke: $fireBrick; + background-color: $fireBrick !important; +} + +.sda-deep-pink { + fill: $deepPink; + stroke: $deepPink; + background-color: $deepPink !important; +} + +.sda-dark-magenta { + fill: $darkMagenta; + stroke: $darkMagenta; + background-color: $darkMagenta !important; +} + +.sda-dark-violet { + fill: $darkViolet; + stroke: $darkViolet; + background-color: $darkViolet !important; +} + +.sda-navy { + fill: $navy; + stroke: $navy; + background-color: $navy !important; +} diff --git a/client/charts/tests/ChartConfig.spec.js b/client/charts/tests/ChartConfig.spec.js index 65d4ff4f9..2dab52d62 100644 --- a/client/charts/tests/ChartConfig.spec.js +++ b/client/charts/tests/ChartConfig.spec.js @@ -106,6 +106,7 @@ describe('chartConfig', () => { }], legend: {enabled: false}, tooltip: { + enabled: true, headerFormat: '{point.x}: {point.y}', pointFormat: '', }, @@ -120,6 +121,7 @@ describe('chartConfig', () => { type: 'bar', xAxis: 0, }], + shadow: true, ...defaultConfig, }); }); @@ -189,8 +191,10 @@ describe('chartConfig', () => { legend: { enabled: true, title: {text: 'Urgency'}, + useHTML: true, }, tooltip: { + enabled: true, headerFormat: '{series.name}/{point.x}: {point.y}', pointFormat: '', }, @@ -221,6 +225,7 @@ describe('chartConfig', () => { stacking: 'normal', stack: 0, }], + shadow: true, ...defaultConfig, }); }); diff --git a/client/charts/views/chart-colour-picker.html b/client/charts/views/chart-colour-picker.html new file mode 100644 index 000000000..699d7882c --- /dev/null +++ b/client/charts/views/chart-colour-picker.html @@ -0,0 +1,12 @@ +
+ + +
diff --git a/client/charts/views/chart-form-options.html b/client/charts/views/chart-form-options.html index 9563f29d8..5a0515fdc 100644 --- a/client/charts/views/chart-form-options.html +++ b/client/charts/views/chart-form-options.html @@ -1,32 +1,32 @@ -
+
-
+
-
+
-
-
+
diff --git a/client/charts/views/chart.html b/client/charts/views/chart.html index 672f10bb3..6ee1f53b6 100644 --- a/client/charts/views/chart.html +++ b/client/charts/views/chart.html @@ -43,4 +43,7 @@
-
+
diff --git a/client/content_publishing_report/views/content-publishing-report-panel.html b/client/content_publishing_report/views/content-publishing-report-panel.html index 6b59edaa2..7e71f1dce 100644 --- a/client/content_publishing_report/views/content-publishing-report-panel.html +++ b/client/content_publishing_report/views/content-publishing-report-panel.html @@ -69,8 +69,12 @@ ng-if="currentTab === 'filters'" data-params="currentParams.params" >
-
diff --git a/client/desk_activity_report/views/desk-activity-report-panel.html b/client/desk_activity_report/views/desk-activity-report-panel.html index d2c1cbc8b..6b806a140 100644 --- a/client/desk_activity_report/views/desk-activity-report-panel.html +++ b/client/desk_activity_report/views/desk-activity-report-panel.html @@ -70,9 +70,12 @@ data-params="currentParams.params" data-fields="sourceFilters" >
- -
diff --git a/client/featuremedia_updates_report/controllers/FeaturemediaUpdatesReportController.js b/client/featuremedia_updates_report/controllers/FeaturemediaUpdatesReportController.js index dd3052ece..d32c78013 100644 --- a/client/featuremedia_updates_report/controllers/FeaturemediaUpdatesReportController.js +++ b/client/featuremedia_updates_report/controllers/FeaturemediaUpdatesReportController.js @@ -1,5 +1,6 @@ import {getErrorMessage} from '../../utils'; import {DATE_FILTERS} from '../../search/directives/DateFilters'; +import {CHART_FIELDS} from '../../charts/directives/ChartOptions'; FeaturemediaUpdatesReportController.$inject = [ '$scope', @@ -58,6 +59,13 @@ export function FeaturemediaUpdatesReportController( DATE_FILTERS.RANGE, ]; + $scope.chartFields = [ + CHART_FIELDS.TITLE, + CHART_FIELDS.SUBTITLE, + CHART_FIELDS.SORT, + CHART_FIELDS.PAGE_SIZE, + ]; + this.initDefaultParams(); savedReports.selectReportFromURL(); diff --git a/client/featuremedia_updates_report/views/featuremedia-updates-report-panel.html b/client/featuremedia_updates_report/views/featuremedia-updates-report-panel.html index 97e34e496..a56a02b1d 100644 --- a/client/featuremedia_updates_report/views/featuremedia-updates-report-panel.html +++ b/client/featuremedia_updates_report/views/featuremedia-updates-report-panel.html @@ -71,8 +71,13 @@ data-fields="sourceFilters" > -
diff --git a/client/planning_usage_report/views/planning-usage-report-panel.html b/client/planning_usage_report/views/planning-usage-report-panel.html index 35c3e15ad..210a9a70b 100644 --- a/client/planning_usage_report/views/planning-usage-report-panel.html +++ b/client/planning_usage_report/views/planning-usage-report-panel.html @@ -59,8 +59,12 @@ ng-include="'planning-usage-report-parameters.html'" > -
diff --git a/client/production_time_report/views/production-time-report-panel.html b/client/production_time_report/views/production-time-report-panel.html index c20deb9d4..fcb275602 100644 --- a/client/production_time_report/views/production-time-report-panel.html +++ b/client/production_time_report/views/production-time-report-panel.html @@ -71,8 +71,12 @@ data-fields="sourceFilters" > -
diff --git a/client/publishing_performance_report/index.js b/client/publishing_performance_report/index.js index f8be1291b..32062a163 100644 --- a/client/publishing_performance_report/index.js +++ b/client/publishing_performance_report/index.js @@ -10,6 +10,7 @@ import * as ctrl from './controllers'; import * as directives from './directives'; +import './widgets'; function cacheIncludedTemplates($templateCache) { $templateCache.put( @@ -30,7 +31,9 @@ cacheIncludedTemplates.$inject = ['$templateCache']; * @packageName analytics.publishing-performance-report * @description Superdesk analytics generate report of Publishing Performance statistics. */ -angular.module('superdesk.analytics.publishing-performance-report', []) +angular.module('superdesk.analytics.publishing-performance-report', + ['superdesk.analytics.publishing-performance-report.widgets'] +) .controller('PublishingPerformanceReportController', ctrl.PublishingPerformanceReportController) .directive('sdaPublishingPerformanceReportPreview', directives.PublishingPerformanceReportPreview) diff --git a/client/publishing_performance_report/views/publishing-performance-report-panel.html b/client/publishing_performance_report/views/publishing-performance-report-panel.html index 159df39d8..2b29037bc 100644 --- a/client/publishing_performance_report/views/publishing-performance-report-panel.html +++ b/client/publishing_performance_report/views/publishing-performance-report-panel.html @@ -70,8 +70,12 @@ data-params="currentParams.params" > -
diff --git a/client/publishing_performance_report/widgets/index.js b/client/publishing_performance_report/widgets/index.js new file mode 100644 index 000000000..45920cb20 --- /dev/null +++ b/client/publishing_performance_report/widgets/index.js @@ -0,0 +1,62 @@ +/** + * This file is part of Superdesk. + * + * Copyright 2018 Sourcefabric z.u. and contributors. + * + * For the full copyright and license information, please see the + * AUTHORS and LICENSE files distributed with this source code, or + * at https://www.sourcefabric.org/superdesk/license + */ + +import {PublishingActionsWidgetController} from './publishing_actions/controller'; + +function cacheIncludedTemplates($templateCache) { + $templateCache.put( + 'publishing-actions-widget.html', + require('./publishing_actions/widget.html') + ); + $templateCache.put( + 'publishing-actions-widget-settings.html', + require('./publishing_actions/settings.html') + ); +} +cacheIncludedTemplates.$inject = ['$templateCache']; + +/** + * @ngdoc module + * @module superdesk.analytics.publishing-performance-report.widgets + * @name superdesk.analytics.publishing-performance-report.widgets + * @packageName analytics.publishing-performance-report.widgets + * @description Superdesk analytics generate report of Publishing Performance statistics. + */ +angular.module('superdesk.analytics.publishing-performance-report.widgets', [ + 'superdesk.apps.dashboard.widgets', + 'superdesk.apps.authoring.widgets', + 'superdesk.apps.desks', + 'superdesk.apps.workspace', + 'superdesk.analytics.charts', +]) + .controller('PublishingActionsWidgetController', PublishingActionsWidgetController) + + .run(cacheIncludedTemplates) + + .config([ + 'dashboardWidgetsProvider', 'gettext', + function(dashboardWidgetsProvider, gettext) { + dashboardWidgetsProvider.addWidget('publishing-actions', { + label: gettext('Publishing Actions'), + description: gettext('Publishing Actions Widget'), + multiple: true, + icon: 'signal', + max_sizex: 1, + max_sizey: 1, + sizex: 1, + sizey: 1, + thumbnail: 'scripts/apps/ingest/ingest-stats-widget/thumbnail.svg', + template: 'publishing-actions-widget.html', + configurationTemplate: 'publishing-actions-widget-settings.html', + custom: true, + removeHeader: true, + }); + }, + ]); diff --git a/client/publishing_performance_report/widgets/publishing_actions/controller.js b/client/publishing_performance_report/widgets/publishing_actions/controller.js new file mode 100644 index 000000000..e784019da --- /dev/null +++ b/client/publishing_performance_report/widgets/publishing_actions/controller.js @@ -0,0 +1,265 @@ +import {DATE_FILTERS} from '../../../search/directives/DateFilters'; +import {CHART_FIELDS} from '../../../charts/directives/ChartOptions'; +import {SDChart} from '../../../charts/SDChart'; +import {CHART_COLOURS} from '../../../charts/directives/ChartColourPicker'; +import {getErrorMessage} from '../../../utils'; + + +PublishingActionsWidgetController.$inject = [ + '$scope', + 'lodash', + 'notify', + 'gettext', + 'searchReport', + 'chartConfig', + 'desks', + '$interval', +]; + +/** + * @ngdoc controller + * @module superdesk.apps.analytics.publishing-performance-report.widgets + * @name PublishingActionsWidgetController + * @requires $scope + * @requires lodash + * @requires notify + * @requires gettext + * @requires searchReport + * @requires chartConfig + * @requires desks + * @requires $interval + * @description Controller for use with the PublishingActions widget and settings views + */ +export function PublishingActionsWidgetController( + $scope, + _, + notify, + gettext, + searchReport, + chartConfig, + desks, + $interval +) { + /** + * @ngdoc method + * @name PublishingActionsWidgetController#init + * @param {Boolean} forSettings - True if init for use with the settings, false for the widget view + * @description Initialises the scope/controller for use with widget or settings view + */ + this.init = (forSettings) => { + $scope.ready = false; + + // This fixes an issue when a controller is created, deleted and created again quickly + // Reduces the chance of multiple api queries happening + $scope.$applyAsync(() => ( + desks.initialize() + .then(() => { + $scope.currentDesk = desks.getCurrentDesk(); + + if (!_.get($scope, 'widget.configuration')) { + $scope.widget.configuration = this.getDefaultConfig(); + } + + if (forSettings) { + this.initForSettings(); + } else { + this.initForWidget(); + } + + $scope.ready = true; + }) + )); + }; + + /** + * @ngdoc method + * @name PublishingActionsWidgetController#getDefaultConfig + * @return {Object} + * @description Returns the default config to use for this widget + */ + this.getDefaultConfig = () => ({ + dates: {filter: DATE_FILTERS.TODAY}, + chart: { + sort_order: 'desc', + title: _.get($scope, 'widget.label', gettext('Publishing Performance')), + colours: { + published: CHART_COLOURS.GREEN, + killed: CHART_COLOURS.RED, + corrected: CHART_COLOURS.BLUE, + updated: CHART_COLOURS.YELLOW, + recalled: CHART_COLOURS.BLACK, + }, + }, + }); + + /** + * @ngdoc method + * @name PublishingActionsWidgetController#initForSettings + * @description Initialise this controller for use with the settings view + */ + this.initForSettings = () => { + $scope.chartFields = [ + CHART_FIELDS.TITLE, + CHART_FIELDS.SORT, + ]; + + $scope.dateFilters = [ + DATE_FILTERS.TODAY, + DATE_FILTERS.THIS_WEEK, + DATE_FILTERS.THIS_MONTH, + DATE_FILTERS.RANGE, + DATE_FILTERS.RELATIVE_DAYS, + DATE_FILTERS.RELATIVE, + ]; + }; + + /** + * @ngdoc method + * @name PublishingActionsWidgetController#initForWidget + * @description Initialise this controller for use with the widget view + */ + this.initForWidget = () => { + /** + * @ngdoc property + * @name PublishingActionsWidgetController#chartConfig + * @type {Object} + * @description The config used to send to the sda-chart directive + */ + $scope.chartConfig = null; + + /** + * @ngdoc property + * @name PublishingActionsWidgetController#interval + * @type {Number} + * @description Used to cancel the $interval for this widget on destruction + */ + this.interval = null; + + /** + * @ngdoc method + * @name PublishingActionsWidgetController#runQuery + * @param {Object} params - Parameters to pass to the API + * @return {Promise} + * @description Sends the query to the API and returns the generated report + */ + this.runQuery = (params) => searchReport.query( + 'publishing_performance_report', + params, + true + ); + + /** + * @ngdoc method + * @name PublishingActionsWidgetController#genConfig + * @param {Object} params - Parameters used for the API + * @param {Object} report - The generated report from the API + * @description Generate the highchart config based on the params and report data + */ + this.genConfig = (params, report) => { + chartConfig.loadTranslations(['state']) + .then(() => { + const numCategories = Object.values(report.subgroups) + .filter((value) => value > 0) + .length; + const numLegendRows = Math.ceil(numCategories / 2); + let legendOffset; + let center; + + switch (numLegendRows) { + case 1: + legendOffset = [0, -10]; + center = ['50%', '100%']; + break; + case 2: + legendOffset = [0, 0]; + center = ['50%', '100%']; + break; + case 3: + legendOffset = [0, 10]; + center = ['50%', '110%']; + break; + } + + const chart = new SDChart.Chart({ + id: $scope.widget._id + '-' + $scope.widget.multiple_id, + fullHeight: true, + exporting: false, + defaultConfig: chartConfig.defaultConfig, + legendFormat: '{y} {name}', + legendOffset: legendOffset, + shadow: false, + translations: chartConfig.translations, + }); + const field = _.get(params, 'aggs.subgroup.field'); + + chart.addAxis() + .setOptions({ + type: 'linear', + defaultChartType: 'pie', + categoryField: field, + categories: Object.keys(report.subgroups), + sortOrder: _.get(params, 'chart.sort_order') || 'asc', + excludeEmpty: true, + }) + .addSeries() + .setOptions({ + field: field, + data: report.subgroups, + colours: _.get(params, 'chart.colours'), + size: 260, + semiCircle: true, + center: center, + showInLegend: true, + }); + + $scope.chartConfig = chart.genConfig(); + $scope.title = $scope.widget.configuration.chart.title || _.get($scope, 'widget.label'); + }); + }; + + $scope.$watch( + 'widget.configuration', + () => $scope.generateChart(), + true + ); + + /** + * @ngdoc method + * @name PublishingActionsWidgetController#generateChart + * @description Sends the params to the API, then generates the new config + */ + $scope.generateChart = () => { + const params = Object.assign( + {}, + _.get($scope, 'widget.configuration') || {}, + { + must: {desks: [$scope.currentDesk._id]}, + must_not: {}, + aggs: { + group: {field: 'task.desk'}, + subgroup: {field: 'state'}, + }, + } + ); + + this.runQuery(params) + .then( + (report) => this.genConfig(params, report), + (error) => { + notify.error( + getErrorMessage( + error, + gettext('Error. The report could not be generated.') + ) + ); + } + ); + }; + + this.interval = $interval($scope.generateChart, 60000); + $scope.$on('$destroy', () => { + $interval.cancel(this.interval); + this.interval = null; + }); + }; +} diff --git a/client/publishing_performance_report/widgets/publishing_actions/settings.html b/client/publishing_performance_report/widgets/publishing_actions/settings.html new file mode 100644 index 000000000..d52d0acf2 --- /dev/null +++ b/client/publishing_performance_report/widgets/publishing_actions/settings.html @@ -0,0 +1,68 @@ +
+ + + +
diff --git a/client/publishing_performance_report/widgets/publishing_actions/widget.html b/client/publishing_performance_report/widgets/publishing_actions/widget.html new file mode 100644 index 000000000..540d7559b --- /dev/null +++ b/client/publishing_performance_report/widgets/publishing_actions/widget.html @@ -0,0 +1,15 @@ +
+
+
{{ title || translate}}
+
+
+
+
+
diff --git a/client/search/directives/DateFilters.js b/client/search/directives/DateFilters.js index 867ae2a1b..dbb28e164 100644 --- a/client/search/directives/DateFilters.js +++ b/client/search/directives/DateFilters.js @@ -15,6 +15,9 @@ export const DATE_FILTERS = { RELATIVE: 'relative', RELATIVE_DAYS: 'relative_days', DAY: 'day', + TODAY: 'today', + THIS_WEEK: 'this_week', + THIS_MONTH: 'this_month', }; /** @@ -103,7 +106,9 @@ export function DateFilters(gettext, moment, $interpolate, config) { delete scope.params.dates.date; } - scope._onFilterChange(); + if (angular.isDefined(scope._onFilterChange)) { + scope._onFilterChange(); + } }; /** diff --git a/client/search/services/SearchReport.js b/client/search/services/SearchReport.js index f53eb5baa..b604b983e 100644 --- a/client/search/services/SearchReport.js +++ b/client/search/services/SearchReport.js @@ -119,39 +119,6 @@ export function SearchReport(_, config, moment, api, $q, gettext, gettextCatalog return report; }; - /** - * @ngdoc method - * @name getUTCOffset - * @param {String} format - The format of the response - * @return {String} - * @description Generates the UTC offset using the format provided - */ - const getUTCOffset = function(format = 'ZZ') { - if (_.get(config, 'search.useDefaultTimezone') && _.get(config, 'defaultTimezone')) { - return moment.tz(config.defaultTimezone).format(format); - } - - return moment().format(format); - }; - - /** - * @ngdoc method - * @name getFilterAndDates - * @param {Object} params - Report parameters - * @return {Object} - * @description Returns the date filter, start, end and date fields from the report parameters - */ - const getFilterAndDates = (params) => { - const dateFilter = _.get(params, 'dates.filter'); - const startDate = _.get(params, 'dates.start'); - const endDate = _.get(params, 'dates.end'); - const date = _.get(params, 'dates.date'); - const relative = _.get(params, 'dates.relative'); - const relativeDays = _.get(params, 'dates.relative_days'); - - return {dateFilter, startDate, endDate, date, relative, relativeDays}; - }; - /** * @ngdoc method * @name filterValues @@ -218,179 +185,6 @@ export function SearchReport(_, config, moment, api, $q, gettext, gettextCatalog } }; - /** - * @ngdoc method - * @name SearchReport#formatDate - * @param {String} date - Date string in the format from config.model.dateformat - * @param {Boolean} endOfDay - If true, sets the time to the end of the day - * @return {String}|null - * @description If date is supplied, then returns a date/time string used in elastic query - */ - const formatDate = function(date, endOfDay = false) { - if (date) { - const timeSuffix = endOfDay ? 'T23:59:59' : 'T00:00:00'; - const utcOffset = getUTCOffset(); - - return moment(date, config.model.dateformat).format('YYYY-MM-DD') + timeSuffix + utcOffset; - } - - return null; - }; - - /** - * @ngdoc method - * @name getFilterValues - * @param {Object} filter - The must/must_not filter - * @return {Object} - * @description Returns the values for the filter attribute in the report parameters - */ - const getFilterValues = (filter) => { - if (_.isArray(filter) || _.isBoolean(filter)) { - return filter; - } - - return _.filter( - Object.keys(filter), - (name) => !!filter[name] - ); - }; - - this._filterDesks = (query, desks, must, params) => { - query[must].push({terms: {'task.desk': desks}}); - }; - - this._filterUsers = (query, users, must, params) => { - query[must].push({terms: {'task.user': users}}); - }; - - this._filterCategories = (query, categories, must, params) => { - const field = _.get(params, 'category_field') || 'qcode'; - - query[must].push({terms: {[`anpa_category.${field}`]: categories}}); - }; - - this._filterSources = (query, sources, must, params) => { - query[must].push({terms: {source: sources}}); - }; - - this._filterGenre = (query, genres, must, params) => { - query[must].push({terms: {'genre.qcode': genres}}); - }; - - this._filterUrgencies = (query, urgencies, must, params) => { - query[must].push( - {terms: {urgency: urgencies.map((val) => parseInt(val, 10))}} - ); - }; - - this._filterIngestProviders = (query, ingests, must, params) => { - query[must].push({terms: {ingest_provider: ingests}}); - }; - - this._filterStages = (query, stages, must, params) => { - query[must].push({terms: {'task.stage': stages}}); - }; - - this._filterStates = (query, states, must, params) => { - query[must].push({terms: {state: states}}); - }; - - this._filterRewrites = (query, value, must, params) => { - if (value) { - query[must].push({exists: {field: 'rewrite_of'}}); - } - }; - - this._includeRewrites = (query, params) => { - const rewrites = params.rewrites || 'include'; - - if (rewrites === 'include') { - return; - } - - const must = rewrites === 'only' ? 'must' : 'must_not'; - - query[must].push({ - and: [ - {term: {state: 'published'}}, - {exists: {field: 'rewrite_of'}}, - ], - }); - }; - - this._setRepos = (query, params) => { - if (_.get(params, 'repos')) { - query.repo = _.compact( - _.map(params.repos, (value, repo) => value && repo) - ).join(','); - } else { - query.repo = ''; - } - }; - - this._setSize = (query, params) => { - query.size = _.get(params, 'size') || 0; - }; - - this._setSort = (query, params) => { - query.sort = _.get(params, 'sort') || [{versioncreated: 'desc'}]; - }; - - this._filterDates = (query, params) => { - const {dateFilter, startDate, endDate, date, relative, relativeDays} = getFilterAndDates(params); - - if (!dateFilter) { - return; - } - - const timeZone = getUTCOffset('Z'); - let lt = null; - let gte = null; - - switch (dateFilter) { - case 'range': - lt = formatDate(endDate, true); - gte = formatDate(startDate); - break; - case 'day': - lt = formatDate(date, true); - gte = formatDate(date); - break; - case 'yesterday': - lt = 'now/d'; - gte = 'now-1d/d'; - break; - case 'last_week': - lt = 'now/w'; - gte = 'now-1w/w'; - break; - case 'last_month': - lt = 'now/M'; - gte = 'now-1M/M'; - break; - case 'relative': - lt = 'now'; - gte = `now-${relative}h`; - break; - case 'relative_days': - lt = 'now'; - gte = `now-${relativeDays}d`; - break; - } - - if (lt !== null && gte !== null) { - query.must.push({ - range: { - versioncreated: { - lt: lt, - gte: gte, - time_zone: timeZone, - }, - }, - }); - } - }; - /** * @ngdoc method * @name constructParams @@ -436,90 +230,16 @@ export function SearchReport(_, config, moment, api, $q, gettext, gettextCatalog return payload; }; - /** - * @ngdoc method - * @name SearchReport@constructQuery - * @param {Object} params - The parameters used to construct the elastic query - * @return {Object} - * @description Constructs an elastic query based on the provided parameters - */ - const constructQuery = (params) => { - const queryFuncs = { - desks: this._filterDesks, - users: this._filterUsers, - categories: this._filterCategories, - sources: this._filterSources, - genre: this._filterGenre, - urgency: this._filterUrgencies, - ingest_providers: this._filterIngestProviders, - stages: this._filterStages, - states: this._filterStates, - rewrites: this._filterRewrites, - }; - - let query = { - must: [], - must_not: [], - }; - - this._setRepos(query, params); - this._setSize(query, params); - this._setSort(query, params); - this._filterDates(query, params); - this._includeRewrites(query, params); - - ['must', 'must_not'].forEach((must) => { - _.forEach(params[must], (filter, field) => { - const values = getFilterValues(filter); - const func = queryFuncs[field]; - - if (_.isArray(values) && _.isEmpty(values) || !func) { - return; - } - - func(query, values, must, params); - }); - }); - - const payload = { - source: { - query: { - filtered: { - filter: { - bool: { - must: query.must, - must_not: query.must_not, - }, - }, - }, - }, - sort: query.sort, - size: query.size, - }, - repo: query.repo, - }; - - if (_.get(params, 'aggs')) { - payload.aggs = params.aggs; - } - - return payload; - }; - /** * @ngdoc method * @name SearchReport#query * @param {String} endpoint - The name of the endpoint to query * @param {Object} params - The parameters used to search elastic - * @param {Object} asObject - Send as param object or elastic query * @return {Object} * @description Constructs an elastic query then sends that query to the provided endpoint */ - this.query = function(endpoint, params, asObject = false) { - return api.query( - endpoint, - asObject ? constructParams(params) : constructQuery(params) - ) + this.query = function(endpoint, params) { + return api.query(endpoint, constructParams(params)) .then( (response) => ( _.get(response, '_items.length', 0) === 1 ? diff --git a/client/search/tests/SearchReport.spec.js b/client/search/tests/SearchReport.spec.js index d3eabaec2..50927e82f 100644 --- a/client/search/tests/SearchReport.spec.js +++ b/client/search/tests/SearchReport.spec.js @@ -25,363 +25,6 @@ describe('searchReport', () => { spyOn(api, 'query').and.returnValue($q.when({_items: []})); })); - const expectBoolQuery = (endpoint, result) => { - expect(api.query).toHaveBeenCalledWith(endpoint, { - source: { - query: { - filtered: { - filter: { - bool: result, - }, - }, - }, - sort: [{versioncreated: 'desc'}], - size: 0, - }, - repo: '', - }); - }; - - it('can call api save for source_category_report endpoint', () => { - searchReport.query('source_category_report', {}); - - expectBoolQuery('source_category_report', { - must: [], - must_not: [], - }); - }); - - it('can generate list of excluded states', () => { - searchReport.query('source_category_report', { - must_not: { - states: { - published: false, - killed: true, - corrected: true, - recalled: true, - }, - }, - }); - - expectBoolQuery('source_category_report', { - must: [], - must_not: [{terms: {state: ['killed', 'corrected', 'recalled']}}], - }); - }); - - it('can generate the list of repos to search', () => { - searchReport.query('source_category_report', { - repos: { - ingest: false, - archive: false, - published: true, - archived: true, - }, - }); - - expect(api.query).toHaveBeenCalledWith('source_category_report', { - source: { - query: { - filtered: { - filter: { - bool: { - must: [], - must_not: [], - }, - }, - }, - }, - sort: [{versioncreated: 'desc'}], - size: 0, - }, - repo: 'published,archived', - }); - }); - - it('can generate the date filters', () => { - // Range - searchReport.query('source_category_report', { - dates: { - filter: 'range', - start: '01/06/2018', - end: '30/06/2018', - }, - }); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: '2018-06-30T23:59:59+0000', - gte: '2018-06-01T00:00:00+0000', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - - // Yesterday - searchReport.query('source_category_report', {dates: {filter: 'yesterday'}}); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: 'now/d', - gte: 'now-1d/d', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - - // Last Week - searchReport.query('source_category_report', {dates: {filter: 'last_week'}}); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: 'now/w', - gte: 'now-1w/w', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - - // Last Month - searchReport.query('source_category_report', {dates: {filter: 'last_month'}}); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: 'now/M', - gte: 'now-1M/M', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - }); - - it('can generate the date filters using date.filter/start/end', () => { - // Range - searchReport.query('source_category_report', { - dates: { - filter: 'range', - start: '01/06/2018', - end: '30/06/2018', - }, - }); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: '2018-06-30T23:59:59+0000', - gte: '2018-06-01T00:00:00+0000', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - - // Yesterday - searchReport.query('source_category_report', {dates: {filter: 'yesterday'}}); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: 'now/d', - gte: 'now-1d/d', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - - // Last Week - searchReport.query('source_category_report', {dates: {filter: 'last_week'}}); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: 'now/w', - gte: 'now-1w/w', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - - // Last Month - searchReport.query('source_category_report', {dates: {filter: 'last_month'}}); - - expectBoolQuery('source_category_report', { - must: [{ - range: { - versioncreated: { - lt: 'now/M', - gte: 'now-1M/M', - time_zone: '+00:00', - }, - }, - }], - must_not: [], - }); - }); - - it('can generate category filters', () => { - searchReport.query('source_category_report', { - must: { - categories: { - Finance: true, - Sport: false, - Advisories: true, - }, - }, - category_field: 'name', - }); - - expectBoolQuery('source_category_report', { - must: [{terms: {'anpa_category.name': ['Finance', 'Advisories']}}], - must_not: [], - }); - - searchReport.query('source_category_report', { - must: { - categories: { - f: true, - a: false, - s: true, - }, - }, - }); - - expectBoolQuery('source_category_report', { - must: [{terms: {'anpa_category.qcode': ['f', 's']}}], - must_not: [], - }); - }); - - it('can generate source filters', () => { - searchReport.query('source_category_report', { - must: { - sources: { - AAP: true, - Reuters: false, - AP: true, - }, - }, - }); - - expectBoolQuery('source_category_report', { - must: [{terms: {source: ['AAP', 'AP']}}], - must_not: [], - }); - }); - - it('can generate exclude rewrite filters', () => { - searchReport.query('source_category_report', { - must_not: { - rewrites: true, - }, - }); - - expectBoolQuery('source_category_report', { - must: [], - must_not: [{exists: {field: 'rewrite_of'}}], - }); - - searchReport.query('source_category_report', { - must_not: { - rewrites: false, - }, - }); - - expectBoolQuery('source_category_report', { - must: [], - must_not: [], - }); - }); - - it('can generate desk filters', () => { - searchReport.query( - 'source_category_report', - {must: {desks: ['desk1', 'desk2']}} - ); - - expectBoolQuery('source_category_report', { - must: [{terms: {'task.desk': ['desk1', 'desk2']}}], - must_not: [], - }); - - searchReport.query( - 'source_category_report', - {must_not: {desks: ['desk1', 'desk2']}} - ); - - expectBoolQuery('source_category_report', { - must: [], - must_not: [{terms: {'task.desk': ['desk1', 'desk2']}}], - }); - }); - - it('can generate user filters', () => { - searchReport.query( - 'source_category_report', - {must: {users: ['user1', 'user2']}} - ); - - expectBoolQuery('source_category_report', { - must: [{terms: {'task.user': ['user1', 'user2']}}], - must_not: [], - }); - - searchReport.query( - 'source_category_report', - {must_not: {users: ['user1', 'user2']}} - ); - - expectBoolQuery('source_category_report', { - must: [], - must_not: [{terms: {'task.user': ['user1', 'user2']}}], - }); - }); - - it('can generate urgency filters', () => { - searchReport.query( - 'source_category_report', - {must: {urgency: [1, 3]}} - ); - - expectBoolQuery('source_category_report', { - must: [{terms: {urgency: [1, 3]}}], - must_not: [], - }); - - searchReport.query( - 'source_category_report', - {must_not: {urgency: [1, 3]}} - ); - - expectBoolQuery('source_category_report', { - must: [], - must_not: [{terms: {urgency: [1, 3]}}], - }); - }); - describe('can send query as param object', () => { it('param object for dates', () => { searchReport.query( @@ -392,8 +35,7 @@ describe('searchReport', () => { start: '01/06/2018', end: '30/06/2018', }, - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { @@ -417,8 +59,7 @@ describe('searchReport', () => { start: '01/06/2018', end: '30/06/2018', }, - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { @@ -438,8 +79,7 @@ describe('searchReport', () => { start: '01/06/2018', end: '30/06/2018', }, - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { @@ -463,8 +103,7 @@ describe('searchReport', () => { start: '01/06/2018', end: '30/06/2018', }, - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { @@ -495,8 +134,7 @@ describe('searchReport', () => { categories: ['a', 'b'], rewrites: false, }, - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { @@ -523,8 +161,7 @@ describe('searchReport', () => { group: {field: 'anpa_category.qcode'}, subgroup: {field: 'urgency'}, }, - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { @@ -552,8 +189,7 @@ describe('searchReport', () => { published: true, archived: true, }, - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { @@ -576,8 +212,7 @@ describe('searchReport', () => { { dates: {filter: 'yesterday'}, return_type: 'highcharts_config', - }, - true + } ); expect(api.query).toHaveBeenCalledWith('source_category_report', { diff --git a/client/search/views/date-filters.html b/client/search/views/date-filters.html index e3a7609a6..ee0b90c3e 100644 --- a/client/search/views/date-filters.html +++ b/client/search/views/date-filters.html @@ -10,6 +10,14 @@ data-label-position="inside" ng-click="onFilterChange()" >{{:: 'Yesterday' | translate }} + + +